Repository: zng-ui/zng (language: rust)

Issue:
Dependency localization
Implement a way for lib crates to export Fluent files.

The scrapper must find these files, the name must be unique


Discussion / hints:
Questions:

* How updates work?
    - Replace template, pseudos, don't touch translations.
    - Implement tooling that compares template keys with translations.
       - Potential issue here, base language not translated when not needed, discourage this?
* Support multiple versions of dependency?
    - Only for breaking changes (keys removed).

API design:

* `cargo zng l10n --package=foo OR --manifest-path=foo/Cargo.toml` scraps `foo/src/**/*.rs` to `foo/l10n`.
* With `--deps` copies dependencies to `foo/l10n/deps/{pkg-name}/` and `foo/l10n/{lang}/deps/{pkg-name}/`.
   - Deps dirs in dependencies are merged, `{dep}/l10n/deps/` are copied to `foo/l10n/deps`, copies after all dependencies copied, does not override files, alphabetic order.
* `l10n!` embeds `CARGO_PKG_NAME`, resolver uses it to search for deps dir first.

Fix patch:
diff --git a/.gitignore b/.gitignore
index a4bbd814a..ee65a3cf6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,4 +20,6 @@ crates/zng-view-prebuilt/lib/libzng_view.*
 do.bat
 do.ps1
 do
-/trace-*.json
\ No newline at end of file
+/trace-*.json
+**/pseudo*.ftl
+**/pseudo*/*.ftl
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
index a78b150ec..fce849638 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -128,7 +128,7 @@
         "ps",
         "pwid",
         "Px",
-        "qps-ploc.flt",
+        "qps-ploc.ftl",
         "qwid",
         "ra",
         "ralt",
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7f05ee5c..c1aebf8f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Unreleased
 
+* **Breaking** Removed support for `{lang}.ftl` localization files, now is named `{lang}/_.ftl`.
+* **Breaking** Removed `L10N.localized_message`, use `L10N.message(..).build_for(lang)`.
+* **Breaking** `cargo zng l10n` CLI refactor.
+    - Now requires arg name for input and output.
+    - Pseudo arg values now define a dir and lang to generate pseudo from.
+* Add `cargo zng l10n --package/--manifest-path` for scrapping from lib crates for publishing.
 * Fix localization scrapper only adding section comments to main file.
 * Add localization helper for commands.
     - Set `l10n!: true` in `command!` declarations to localize metadata.
diff --git a/crates/cargo-zng/Cargo.toml b/crates/cargo-zng/Cargo.toml
index 0aa07986d..068d439cf 100644
--- a/crates/cargo-zng/Cargo.toml
+++ b/crates/cargo-zng/Cargo.toml
@@ -20,8 +20,12 @@ color-print = "0.3"
 rayon = "1.10"
 glob = "0.3"
 fluent-pseudo = "0.3"
+fluent-syntax = "0.11"
 proc-macro2 = { version = "1.0", features = ["span-locations"] }
 litrs = "0.4"
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+semver = "1.0"
 
 # new
 convert_case = "0.6"
diff --git a/crates/cargo-zng/src/l10n.rs b/crates/cargo-zng/src/l10n.rs
index 8b4c8201e..82c1281cd 100644
--- a/crates/cargo-zng/src/l10n.rs
+++ b/crates/cargo-zng/src/l10n.rs
@@ -4,109 +4,288 @@
 //!
 //! [`l10n!`]: https://zng-ui.github.io/doc/zng/l10n/macro.l10n.html#scrap-template
 
-use std::{borrow::Cow, io::Write, path::PathBuf};
+use std::{
+    cmp::Ordering,
+    fs,
+    io::{self, Write},
+    path::{Path, PathBuf},
+};
 
 use clap::*;
 
+use crate::util;
+
 mod pseudo;
 mod scraper;
 
 #[derive(Args, Debug)]
 pub struct L10nArgs {
     /// Rust files glob or directory
+    #[arg(short, long, default_value = "")]
     input: String,
 
-    /// Lang resources dir
-    output: PathBuf,
+    /// L10n resources dir
+    #[arg(short, long, default_value = "")]
+    output: String,
+
+    /// Package to scrap and copy dependencies
+    ///
+    /// If set the --input and --output default is src/**.rs and l10n/
+    #[arg(short, long, default_value = "")]
+    package: String,
+
+    /// Path to Cargo.toml of crate to scrap and copy dependencies
+    ///
+    /// If set the --input and --output default to src/**.rs and l10n/
+    #[arg(long, default_value = "")]
+    manifest_path: String,
+
+    /// Don't copy dependencies localization
+    ///
+    /// Use with --package or --manifest-path to copy {dep-pkg}/l10n/*.ftl files
+    #[arg(long, action)]
+    no_deps: bool,
 
     /// Custom l10n macro names, comma separated
     #[arg(short, long, default_value = "")]
     macros: String,
 
-    /// Pseudo Base name, empty to disable
-    #[arg(long, default_value = "pseudo")]
+    /// Generate pseudo locale from dir/lang
+    ///
+    /// EXAMPLE
+    ///
+    /// "l10n/en" generates pseudo from "l10n/en.ftl" and "l10n/en/*.ftl"
+    #[arg(long, default_value = "")]
     pseudo: String,
-    /// Pseudo Mirrored name, empty to disable
-    #[arg(long, default_value = "pseudo-mirr")]
+    /// Generate pseudo mirrored locale
+    #[arg(long, default_value = "")]
     pseudo_m: String,
-    /// Pseudo Wide name, empty to disable
-    #[arg(long, default_value = "pseudo-wide")]
+    /// Generate pseudo wide locale
+    #[arg(long, default_value = "")]
     pseudo_w: String,
+
+    /// Only verify that the generated files are the same
+    #[arg(long, action)]
+    check: bool, // !!: TODO
 }
 
-pub fn run(args: L10nArgs) {
-    let mut input = args.input;
-    if !input.contains('*') && PathBuf::from(&input).is_dir() {
-        input = format!("{}/**/*.rs", input.trim_end_matches(&['\\', '/']));
+pub fn run(mut args: L10nArgs) {
+    if !args.package.is_empty() && !args.manifest_path.is_empty() {
+        fatal!("only one of --package --manifest-path must be set")
     }
 
-    println!(r#"searching "{input}".."#);
+    let mut input = String::new();
+    let mut output = args.output.replace('\\', "/");
 
-    let custom_macro_names: Vec<&str> = args.macros.split(',').map(|n| n.trim()).collect();
+    if !args.input.is_empty() {
+        input = args.input.replace('\\', "/");
 
-    if let Err(e) = std::fs::create_dir_all(&args.output) {
-        fatal!("cannot create dir `{}`, {e}", args.output.display());
+        if !input.contains('*') && PathBuf::from(&input).is_dir() {
+            input = format!("{}/**/*.rs", input.trim_end_matches('/'));
+        }
     }
-
-    let mut template = scraper::scrape_fluent_text(&input, &custom_macro_names);
-    match template.entries.len() {
-        0 => {
-            println!("did not find any entry");
-            return;
+    if !args.package.is_empty() {
+        if let Some(m) = crate::util::manifest_path_from_package(&args.package) {
+            args.manifest_path = m;
+        } else {
+            fatal!("package `{}` not found in workspace", args.package);
         }
-        1 => println!("found 1 entry"),
-        n => println!("found {n} entries"),
     }
 
-    struct Task {
-        name: String,
-        transform: fn(&str) -> Cow<str>,
-    }
-    let mut tasks = vec![Task {
-        name: "template".to_owned(),
-        transform: pseudo::none,
-    }];
-    if !args.pseudo.is_empty() {
-        tasks.push(Task {
-            name: args.pseudo,
-            transform: pseudo::pseudo,
-        })
-    }
-    if !args.pseudo_m.is_empty() {
-        tasks.push(Task {
-            name: args.pseudo_m,
-            transform: pseudo::pseudo_mirr,
-        })
-    }
-    if !args.pseudo_w.is_empty() {
-        tasks.push(Task {
-            name: args.pseudo_w,
-            transform: pseudo::pseudo_wide,
-        })
+    if !args.manifest_path.is_empty() {
+        if !Path::new(&args.manifest_path).exists() {
+            fatal!("{input} does not exist")
+        }
+
+        if let Some(path) = args.manifest_path.replace('\\', "/").strip_suffix("/Cargo.toml") {
+            if output.is_empty() {
+                output = format!("{path}/l10n");
+            }
+            if input.is_empty() {
+                input = format!("{path}/src/**/*.rs");
+            }
+        } else {
+            fatal!("expected path to Cargo.toml manifest file");
+        }
     }
 
-    template.sort();
+    if !input.is_empty() {
+        if output.is_empty() {
+            fatal!("--output is required for --input")
+        }
+
+        println!(r#"scraping "{input}".."#);
+
+        let custom_macro_names: Vec<&str> = args.macros.split(',').map(|n| n.trim()).collect();
+        // let args = ();
+
+        let mut template = scraper::scrape_fluent_text(&input, &custom_macro_names);
+        match template.entries.len() {
+            0 => println!("did not find any entry"),
+            1 => println!("found 1 entry"),
+            n => println!("found {n} entries"),
+        }
 
-    for task in tasks {
-        let r = template.write(task.transform, |file| {
-            fn box_dyn(file: std::fs::File) -> Box<dyn Write + Send> {
-                Box::new(file)
+        if !template.entries.is_empty() || !template.notes.is_empty() {
+            if let Err(e) = std::fs::create_dir_all(&output) {
+                fatal!("cannot create dir `{output}`, {e}");
             }
 
-            let mut output = args.output.clone();
-            if file.is_empty() {
-                output.push(format!("{}.ftl", task.name));
-            } else {
-                output.push(&task.name);
+            template.sort();
+
+            let r = template.write(|file| {
+                fn box_dyn(file: std::fs::File) -> Box<dyn Write + Send> {
+                    Box::new(file)
+                }
+
+                let mut output = PathBuf::from(&output);
+                output.push("template");
                 std::fs::create_dir_all(&output)?;
-                output.push(format!("{file}.ftl"));
+                output.push(format!("{}.ftl", if file.is_empty() { "_" } else { file }));
+                std::fs::File::create(output).map(box_dyn)
+            });
+            if let Err(e) = r {
+                fatal!("error writing template files, {e}");
             }
-            std::fs::File::create(output).map(box_dyn)
-        });
+        }
+
+        if !args.no_deps {
+            let mut count = 0;
+            for dep in util::dependencies(&args.manifest_path) {
+                let dep_l10n = dep.manifest_path.with_file_name("l10n");
+                let dep_l10n_reader = match fs::read_dir(&dep_l10n) {
+                    Ok(d) => d,
+                    Err(e) => {
+                        if !matches!(e.kind(), io::ErrorKind::NotFound) {
+                            error!("cannot read `{}`, {e}", dep_l10n.display());
+                        }
+                        continue;
+                    }
+                };
+
+                let mut any = false;
+
+                let l10n_dir = Path::new(&output);
+                // get l10n_dir/{lang}/deps/dep.name/dep.version/
+                let mut l10n_dir = |lang: Option<&std::ffi::OsStr>| {
+                    any = true;
+                    let dir = match lang {
+                        Some(l) => l10n_dir.join(l).join("deps"),
+                        None => l10n_dir.join("deps"),
+                    };
+                    let ignore_file = dir.join(".gitignore");
+
+                    if !ignore_file.exists() {
+                        // create dir and .gitignore file
+                        (|| -> io::Result<()> {
+                            fs::create_dir_all(&dir)?;
+                            let mut f = io::BufWriter::new(fs::File::options().create(true).truncate(true).write(true).open(ignore_file)?);
+                            writeln!(&mut f, "# Dependency localization files")?;
+                            if !args.package.is_empty() {
+                                writeln!(&mut f, "# Call `cargo zng l10n --package {}` to update", args.package)?;
+                            } else {
+                                let path = Path::new(&args.manifest_path);
+                                let path = path.strip_prefix(std::env::current_dir().unwrap()).unwrap_or(path);
+                                writeln!(&mut f, "# Call `cargo zng l10n --manifest-path {}` to update", path.display())?;
+                            }
+                            writeln!(&mut f)?;
+                            writeln!(&mut f, "*")?;
+                            writeln!(&mut f, "!.gitignore")?;
+                            f.flush()?;
+                            Ok(())
+                        })()
+                        .unwrap_or_else(|e| fatal!("cannot create `{}`, {e}", l10n_dir.display()));
+                    }
+
+                    let dir = dir.join(&dep.name).join(dep.version.to_string());
+                    let _ = fs::create_dir_all(&dir);
+
+                    dir
+                };
+
+                // [(exporter_dep, ".../{lang}?/deps")]
+                let mut reexport_deps = vec![];
+
+                for dep_l10n_entry in dep_l10n_reader {
+                    let dep_l10n_entry = match dep_l10n_entry {
+                        Ok(e) => e.path(),
+                        Err(e) => {
+                            error!("cannot read `{}` entry, {e}", dep_l10n.display());
+                            continue;
+                        }
+                    };
+                    if dep_l10n_entry.is_dir() {
+                        // l10n/{lang}/deps/{dep.name}/{dep.version}
+                        let output_dir = l10n_dir(dep_l10n_entry.file_name());
+                        let _ = fs::create_dir_all(&output_dir);
+
+                        let lang_dir_reader = match fs::read_dir(&dep_l10n_entry) {
+                            Ok(d) => d,
+                            Err(e) => {
+                                error!("cannot read `{}`, {e}", dep_l10n_entry.display());
+                                continue;
+                            }
+                        };
+
+                        for lang_entry in lang_dir_reader {
+                            let lang_entry = match lang_entry {
+                                Ok(e) => e.path(),
+                                Err(e) => {
+                                    error!("cannot read `{}` entry, {e}", dep_l10n_entry.display());
+                                    continue;
+                                }
+                            };
 
-        match r {
-            Ok(()) => println!("finished {:?}.", task.name),
-            Err(e) => fatal!("{:?} error: {}", task.name, e),
+                            if lang_entry.is_dir() {
+                                if lang_entry.file_name().map(|n| n == "deps").unwrap_or(false) {
+                                    reexport_deps.push((&dep, lang_entry));
+                                }
+                            } else if lang_entry.is_file() && lang_entry.extension().map(|e| e == "ftl").unwrap_or(false) {
+                                let _ = fs::create_dir_all(&output_dir);
+                                let to = output_dir.join(lang_entry.file_name().unwrap());
+                                if let Err(e) = fs::copy(&lang_entry, &to) {
+                                    error!("cannot copy `{}` to `{}`, {e}", lang_entry.display(), to.display());
+                                    continue;
+                                }
+                            }
+                        }
+                    }
+                }
+
+                reexport_deps.sort_by(|a, b| match a.0.name.cmp(&b.0.name) {
+                    Ordering::Equal => b.0.version.cmp(&a.0.version),
+                    o => o,
+                });
+
+                for (_, deps) in reexport_deps {
+                    // dep/l10n/lang/deps/
+                    let target = l10n_dir(deps.parent().and_then(|p| p.file_name()));
+
+                    // deps/pkg-name/pkg-version/*.ftl
+                    for entry in glob::glob(&deps.join("*/*/*.ftl").display().to_string()).unwrap() {
+                        let entry = entry.unwrap_or_else(|e| fatal!("cannot read `{}` entry, {e}", deps.display()));
+                        let target = target.join(entry.strip_prefix(&deps).unwrap());
+                        if !target.exists() && entry.is_file() {
+                            if let Err(e) = fs::copy(&entry, &target) {
+                                error!("cannot copy `{}` to `{}`, {e}", entry.display(), target.display());
+                            }
+                        }
+                    }
+                }
+
+                count += any as u32;
+            }
+            println!("found {count} dependencies with localization");
         }
     }
+
+    if !args.pseudo.is_empty() {
+        pseudo::pseudo(&args.pseudo);
+    }
+    if !args.pseudo_m.is_empty() {
+        pseudo::pseudo_mirr(&args.pseudo_m);
+    }
+    if !args.pseudo_w.is_empty() {
+        pseudo::pseudo_wide(&args.pseudo_w);
+    }
 }
diff --git a/crates/cargo-zng/src/l10n/pseudo.rs b/crates/cargo-zng/src/l10n/pseudo.rs
index b3b6b503a..b68ffde70 100644
--- a/crates/cargo-zng/src/l10n/pseudo.rs
+++ b/crates/cargo-zng/src/l10n/pseudo.rs
@@ -1,60 +1,185 @@
-use std::borrow::Cow;
+use std::{borrow::Cow, fmt::Write as _, fs, path::Path};
 
-pub fn pseudo(s: &str) -> Cow<str> {
-    fluent_pseudo_impl(s, false, false)
+use fluent_syntax::ast::*;
+
+pub fn pseudo(dir: &str) {
+    fluent_pseudo_impl(dir, "pseudo", false, false)
+}
+
+pub fn pseudo_mirr(dir: &str) {
+    fluent_pseudo_impl(dir, "pseudo-mirr", true, false)
 }
 
-pub fn pseudo_mirr(s: &str) -> Cow<str> {
-    fluent_pseudo_impl(s, true, false)
+pub fn pseudo_wide(dir: &str) {
+    fluent_pseudo_impl(dir, "pseudo-wide", false, true)
 }
 
-pub fn pseudo_wide(s: &str) -> Cow<str> {
-    fluent_pseudo_impl(s, false, true)
+fn fluent_pseudo_impl(dir: &str, to_name: &str, flipped: bool, elongate: bool) {
+    pseudo_impl(dir, to_name, &|s| fluent_pseudo::transform(s, flipped, elongate))
 }
 
-pub fn none(s: &str) -> Cow<str> {
-    Cow::Borrowed(s)
+fn pseudo_impl(dir: &str, to_name: &str, transform: &impl Fn(&str) -> Cow<str>) {
+    let dir = Path::new(dir);
+    let to_dir = dir.with_file_name(to_name);
+
+    for entry in fs::read_dir(dir).unwrap_or_else(|e| fatal!("cannot read `{}`, {e}", dir.display())) {
+        let entry = entry.unwrap_or_else(|e| fatal!("cannot read `{}` entry, {e}", dir.display()));
+        let path = entry.path();
+        if path.is_file() && path.extension().map(|e| e == "ftl").unwrap_or(false) {
+            let _ = fs::create_dir(&to_dir);
+            generate(&path, &to_dir.join(path.file_name().unwrap()), transform);
+        }
+    }
+
+    let unnamed = dir.with_extension("ftl");
+    if unnamed.exists() {
+        generate(&unnamed, &to_dir.with_extension("ftl"), transform);
+    }
 }
 
-fn fluent_pseudo_impl(s: &str, flipped: bool, elongate: bool) -> Cow<str> {
-    pseudo_impl(s, |s| fluent_pseudo::transform(s, flipped, elongate))
+fn generate(from: &Path, to: &Path, transform: &impl Fn(&str) -> Cow<str>) {
+    let source = match fs::read_to_string(from) {
+        Ok(s) => s,
+        Err(e) => {
+            error!("cannot read `{}`, {e}", from.display());
+            return;
+        }
+    };
+
+    let source = match fluent_syntax::parser::parse(source) {
+        Ok(s) => s,
+        Err((s, e)) => {
+            error!(
+                "cannot parse `{}`\n{}",
+                from.display(),
+                e.into_iter().map(|e| format!("    {e}")).collect::<Vec<_>>().join("\n")
+            );
+            s
+        }
+    };
+
+    let mut output = "# Test locale, generated by cargo zng l10n".to_owned();
+
+    for entry in source.body {
+        match entry {
+            Entry::Message(m) => write_entry(&mut output, &m.id, m.value.as_ref(), &m.attributes, transform),
+            Entry::Term(t) => write_entry(&mut output, &t.id, Some(&t.value), &t.attributes, transform),
+            Entry::Comment(_) | Entry::GroupComment(_) | Entry::ResourceComment(_) | Entry::Junk { .. } => {}
+        }
+    }
+
+    if let Err(e) = fs::write(to, output.as_bytes()) {
+        error!("cannot write `{}`, {e}", to.display());
+    } else {
+        println!("  generated {}", to.display());
+    }
 }
 
-fn pseudo_impl(s: &str, transform: impl Fn(&str) -> Cow<str>) -> Cow<str> {
-    if s.contains('{') {
-        let mut r = String::with_capacity(s.len());
+fn write_entry(
+    output: &mut String,
+    id: &Identifier<String>,
+    value: Option<&Pattern<String>>,
+    attributes: &[Attribute<String>],
+    transform: &impl Fn(&str) -> Cow<str>,
+) {
+    write!(output, "\n\n{} = ", id.name).unwrap();
+    if let Some(value) = value {
+        write_pattern(output, value, transform);
+    }
+    for attr in attributes {
+        write!(output, "\n    .{} = ", attr.id.name).unwrap();
+        write_pattern(output, &attr.value, transform);
+    }
+}
 
-        let mut start = 0;
-        let mut depth = 0;
-        for (i, c) in s.char_indices() {
-            match c {
-                '{' => {
-                    if depth == 0 {
-                        r.push_str(&transform(&s[start..i]));
-                        start = i;
-                    }
-                    depth += 1;
+fn write_pattern(output: &mut String, pattern: &Pattern<String>, transform: &impl Fn(&str) -> Cow<str>) {
+    for el in &pattern.elements {
+        match el {
+            PatternElement::TextElement { value } => {
+                let mut prefix = "";
+                for line in value.lines() {
+                    write!(output, "{prefix}{}", transform(line)).unwrap();
+                    prefix = "    ";
                 }
-                '}' => {
-                    depth -= 1;
-                    if depth == 0 {
-                        r.push_str(&s[start..=i]);
-                        start = i + 1;
-                    }
+            }
+            PatternElement::Placeable { expression } => write_expression(output, expression, transform),
+        }
+    }
+}
+
+fn write_expression(output: &mut String, expr: &Expression<String>, transform: &impl Fn(&str) -> Cow<str>) {
+    match expr {
+        Expression::Select { selector, variants } => {
+            write!(output, "{{").unwrap();
+            write_inline_expression(output, selector, transform);
+            writeln!(output, " ->").unwrap();
+
+            for v in variants {
+                write!(output, "    ").unwrap();
+                if v.default {
+                    write!(output, "*").unwrap();
                 }
-                _ => {}
+                let key = match &v.key {
+                    VariantKey::Identifier { name } => name,
+                    VariantKey::NumberLiteral { value } => value,
+                };
+                write!(output, "[{key}] ").unwrap();
+
+                write_pattern(output, &v.value, transform);
+            }
+
+            writeln!(output, "}}").unwrap();
+        }
+        Expression::Inline(e) => write_inline_expression(output, e, transform),
+    }
+}
+fn write_inline_expression(output: &mut String, expr: &InlineExpression<String>, transform: &impl Fn(&str) -> Cow<str>) {
+    match expr {
+        InlineExpression::StringLiteral { value } => write!(output, "{{ \"{value}\" }}").unwrap(),
+        InlineExpression::NumberLiteral { value } => write!(output, "{{ {value} }}").unwrap(),
+        InlineExpression::FunctionReference { id, arguments } => {
+            write!(output, "{{ {}", id.name).unwrap();
+            write_arguments(output, arguments, transform);
+            write!(output, " }}").unwrap()
+        }
+        InlineExpression::MessageReference { id, attribute } => {
+            write!(output, "{{ {}", id.name).unwrap();
+            if let Some(a) = attribute {
+                write!(output, ".{}", a.name).unwrap();
             }
+            write!(output, " }}").unwrap()
         }
-        if start < s.len() {
-            if depth == 0 {
-                r.push_str(&transform(&s[start..]));
-            } else {
-                r.push_str(&s[start..]);
+        InlineExpression::TermReference { id, attribute, arguments } => {
+            write!(output, "{{ -{}", id.name).unwrap();
+            if let Some(a) = attribute {
+                write!(output, ".{}", a.name).unwrap();
+            }
+            if let Some(args) = arguments {
+                write_arguments(output, args, transform);
             }
+            write!(output, " }}").unwrap()
         }
+        InlineExpression::VariableReference { id } => write!(output, "{{ ${} }}", id.name).unwrap(),
+        InlineExpression::Placeable { expression } => {
+            write!(output, "{{ ").unwrap();
+            write_expression(output, expression, transform);
+            write!(output, " }}").unwrap();
+        }
+    }
+}
 
-        Cow::Owned(r)
-    } else {
-        transform(s)
+fn write_arguments(output: &mut String, arguments: &CallArguments<String>, transform: &impl Fn(&str) -> Cow<str>) {
+    write!(output, "(").unwrap();
+    let mut sep = "";
+    for a in &arguments.positional {
+        write!(output, "{sep}").unwrap();
+        write_inline_expression(output, a, transform);
+        sep = ", ";
+    }
+    for a in &arguments.named {
+        write!(output, "{sep}{}", a.name.name).unwrap();
+        write_inline_expression(output, &a.value, transform);
+        sep = ", ";
     }
+    write!(output, ")").unwrap();
 }
diff --git a/crates/cargo-zng/src/l10n/scraper.rs b/crates/cargo-zng/src/l10n/scraper.rs
index 88a619227..746c2a5e9 100644
--- a/crates/cargo-zng/src/l10n/scraper.rs
+++ b/crates/cargo-zng/src/l10n/scraper.rs
@@ -1,6 +1,6 @@
 //! Localization text scraping.
 
-use std::{borrow::Cow, fs, io, mem, path::PathBuf, sync::Arc};
+use std::{fs, io, mem, path::PathBuf, sync::Arc};
 
 use litrs::StringLit;
 use proc_macro2::{Delimiter, Ident, Span, TokenStream, TokenTree};
@@ -596,11 +596,7 @@ impl FluentTemplate {
     ///
     /// The `select_l10n_file` closure is called once for each different file, it must return
     /// a writer that will be the output file.
-    pub fn write(
-        &self,
-        transform_msg: fn(&str) -> Cow<str>,
-        select_l10n_file: impl Fn(&str) -> io::Result<Box<dyn io::Write + Send>> + Send + Sync,
-    ) -> io::Result<()> {
+    pub fn write(&self, select_l10n_file: impl Fn(&str) -> io::Result<Box<dyn io::Write + Send>> + Send + Sync) -> io::Result<()> {
         let mut file = None;
         let mut output = None;
         let mut section = "";
@@ -688,7 +684,7 @@ impl FluentTemplate {
                 if entry.attribute.is_empty() {
                     let mut prefix = " ";
 
-                    for line in transform_msg(&entry.message).lines() {
+                    for line in entry.message.lines() {
                         output.write_fmt(format_args!("{prefix}{line}\n"))?;
                         prefix = "    ";
                     }
@@ -699,7 +695,7 @@ impl FluentTemplate {
             if !entry.attribute.is_empty() {
                 output.write_fmt(format_args!("    .{} = ", entry.attribute))?;
                 let mut prefix = "";
-                for line in transform_msg(&entry.message).lines() {
+                for line in entry.message.lines() {
                     output.write_fmt(format_args!("{prefix}{line}\n"))?;
                     prefix = "        ";
                 }
diff --git a/crates/cargo-zng/src/util.rs b/crates/cargo-zng/src/util.rs
index f65d1191c..1cc0f7bf1 100644
--- a/crates/cargo-zng/src/util.rs
+++ b/crates/cargo-zng/src/util.rs
@@ -1,10 +1,14 @@
 use std::{
+    collections::HashMap,
     io,
     path::{Path, PathBuf},
-    process::Command,
+    process::{Command, Stdio},
     sync::atomic::AtomicBool,
 };
 
+use semver::{Version, VersionReq};
+use serde::Deserialize;
+
 /// Print warning message.
 macro_rules! warn {
     ($($format_args:tt)*) => {
@@ -169,3 +173,127 @@ pub fn clean_value(value: &str, required: bool) -> io::Result<String> {
     }
     Ok(clean_value)
 }
+
+pub fn manifest_path_from_package(package: &str) -> Option<String> {
+    let metadata = match Command::new("cargo")
+        .args(["metadata", "--format-version", "1", "--no-deps"])
+        .stderr(Stdio::inherit())
+        .output()
+    {
+        Ok(m) => {
+            if !m.status.success() {
+                fatal!("cargo metadata error")
+            }
+            String::from_utf8_lossy(&m.stdout).into_owned()
+        }
+        Err(e) => fatal!("cargo metadata error, {e}"),
+    };
+
+    #[derive(Deserialize)]
+    struct Metadata {
+        packages: Vec<Package>,
+    }
+    #[derive(Deserialize)]
+    struct Package {
+        name: String,
+        manifest_path: String,
+    }
+    let metadata: Metadata = serde_json::from_str(&metadata).unwrap_or_else(|e| fatal!("unexpected cargo metadata format, {e}"));
+
+    for p in metadata.packages {
+        if p.name == package {
+            return Some(p.manifest_path);
+        }
+    }
+    None
+}
+
+/// Dependencies of manifest_path
+pub fn dependencies(manifest_path: &str) -> Vec<DependencyManifest> {
+    let metadata = match Command::new("cargo")
+        .args(["metadata", "--format-version", "1", "--manifest-path"])
+        .arg(manifest_path)
+        .stderr(Stdio::inherit())
+        .output()
+    {
+        Ok(m) => {
+            if !m.status.success() {
+                fatal!("cargo metadata error")
+            }
+            String::from_utf8_lossy(&m.stdout).into_owned()
+        }
+        Err(e) => fatal!("cargo metadata error, {e}"),
+    };
+
+    #[derive(Deserialize)]
+    struct Metadata {
+        packages: Vec<Package>,
+    }
+    #[derive(Deserialize)]
+    struct Package {
+        name: String,
+        version: Version,
+        dependencies: Vec<Dependency>,
+        manifest_path: String,
+    }
+    #[derive(Deserialize)]
+    struct Dependency {
+        name: String,
+        kind: Option<String>,
+        req: VersionReq,
+    }
+
+    let metadata: Metadata = serde_json::from_str(&metadata).unwrap_or_else(|e| fatal!("unexpected cargo metadata format, {e}"));
+
+    let manifest_path = dunce::canonicalize(manifest_path).unwrap();
+
+    let mut dependencies: &[Dependency] = &[];
+
+    for pkg in &metadata.packages {
+        let pkg_path = Path::new(&pkg.manifest_path);
+        if pkg_path == manifest_path {
+            dependencies = &pkg.dependencies;
+            break;
+        }
+    }
+    if !dependencies.is_empty() {
+        let mut map = HashMap::new();
+        for pkg in &metadata.packages {
+            map.entry(pkg.name.as_str()).or_insert_with(Vec::new).push((&pkg.version, pkg));
+        }
+
+        let mut r = vec![];
+        fn collect(map: &mut HashMap<&str, Vec<(&Version, &Package)>>, dependencies: &[Dependency], r: &mut Vec<DependencyManifest>) {
+            for dep in dependencies {
+                if dep.kind.is_some() {
+                    // skip build/dev-dependencies
+                    continue;
+                }
+                if let Some(versions) = map.remove(dep.name.as_str()) {
+                    for (version, pkg) in versions.iter() {
+                        if dep.req.matches(version) {
+                            r.push(DependencyManifest {
+                                name: pkg.name.clone(),
+                                version: pkg.version.clone(),
+                                manifest_path: pkg.manifest_path.as_str().into(),
+                            });
+
+                            // collect dependencies of dependencies
+                            collect(map, &pkg.dependencies, r)
+                        }
+                    }
+                }
+            }
+        }
+        collect(&mut map, dependencies, &mut r);
+        return r;
+    }
+
+    vec![]
+}
+
+pub struct DependencyManifest {
+    pub name: String,
+    pub version: Version,
+    pub manifest_path: PathBuf,
+}
diff --git a/crates/zng-app/l10n/template/_.ftl b/crates/zng-app/l10n/template/_.ftl
new file mode 100644
index 000000000..a8c2c14de
--- /dev/null
+++ b/crates/zng-app/l10n/template/_.ftl
@@ -0,0 +1,6 @@
+EXIT_CMD =
+    .info = Close all windows and exit.
+    .name = Exit
+
+OPEN_LICENSES_CMD =
+    .name = Third Party Licenses
diff --git a/crates/zng-app/src/event/command.rs b/crates/zng-app/src/event/command.rs
index 99d5eb5a6..bbfde8a2e 100644
--- a/crates/zng-app/src/event/command.rs
+++ b/crates/zng-app/src/event/command.rs
@@ -206,7 +206,7 @@ macro_rules! __command {
                     let __l10n_arg = $l10n_arg;
                     $(
                         cmd.[<init_ $meta_ident>]($meta_init);
-                        $crate::event::init_meta_l10n(&__l10n_arg, cmd, stringify!($meta_ident), &cmd.$meta_ident());
+                        $crate::event::init_meta_l10n(std::env!("CARGO_PKG_NAME"), std::env!("CARGO_PKG_VERSION"), &__l10n_arg, cmd, stringify!($meta_ident), &cmd.$meta_ident());
                     )*
                 };
             }
@@ -251,7 +251,14 @@ macro_rules! __command {
 }
 
 #[doc(hidden)]
-pub fn init_meta_l10n(l10n_arg: &dyn Any, cmd: Command, meta_name: &'static str, meta_value: &dyn Any) {
+pub fn init_meta_l10n(
+    pkg_name: &'static str,
+    pkg_version: &'static str,
+    l10n_arg: &dyn Any,
+    cmd: Command,
+    meta_name: &'static str,
+    meta_value: &dyn Any,
+) {
     if let Some(txt) = meta_value.downcast_ref::<CommandMetaVar<Txt>>() {
         let mut l10n_file = "";
 
@@ -266,7 +273,7 @@ pub fn init_meta_l10n(l10n_arg: &dyn Any, cmd: Command, meta_name: &'static str,
             return;
         }
 
-        EVENTS_L10N.init_meta_l10n(l10n_file, cmd, meta_name, txt.clone());
+        EVENTS_L10N.init_meta_l10n([pkg_name, pkg_version, l10n_file], cmd, meta_name, txt.clone());
     }
 }
 
diff --git a/crates/zng-app/src/event/events.rs b/crates/zng-app/src/event/events.rs
index 92049c98d..8a7c851f8 100644
--- a/crates/zng-app/src/event/events.rs
+++ b/crates/zng-app/src/event/events.rs
@@ -18,8 +18,8 @@ pub(crate) struct EventsService {
     l10n: EventsL10n,
 }
 enum EventsL10n {
-    Pending(Vec<(&'static str, Command, &'static str, CommandMetaVar<Txt>)>),
-    Init(Box<dyn Fn(&'static str, Command, &'static str, CommandMetaVar<Txt>) + Send + Sync>),
+    Pending(Vec<([&'static str; 3], Command, &'static str, CommandMetaVar<Txt>)>),
+    Init(Box<dyn Fn([&'static str; 3], Command, &'static str, CommandMetaVar<Txt>) + Send + Sync>),
 }
 impl EventsService {
     const fn new() -> Self {
@@ -123,7 +123,7 @@ impl EVENTS {
 #[allow(non_camel_case_types)]
 pub struct EVENTS_L10N;
 impl EVENTS_L10N {
-    pub(crate) fn init_meta_l10n(&self, file: &'static str, cmd: Command, meta_name: &'static str, txt: CommandMetaVar<Txt>) {
+    pub(crate) fn init_meta_l10n(&self, file: [&'static str; 3], cmd: Command, meta_name: &'static str, txt: CommandMetaVar<Txt>) {
         {
             let sv = EVENTS_SV.read();
             if let EventsL10n::Init(f) = &sv.l10n {
@@ -143,11 +143,12 @@ impl EVENTS_L10N {
     ///
     /// The closure arguments are:
     ///
-    /// * `file` is the command declaration `@l10n: "file"` value or is empty if `@l10n` was set to something else.
+    /// * `file` is the crate package name, version and the file from command declaration `@l10n: "file"`
+    ///    value or is empty if `@l10n` was set to something else.
     /// * `cmd` is the command, the command event name should be used as key.
     /// * `meta` is the metadata name, for example `"name"`, should be used as attribute.
     /// * `txt` is text variable that must be set with the translation.
-    pub fn init_l10n(&self, localize: impl Fn(&'static str, Command, &'static str, CommandMetaVar<Txt>) + Send + Sync + 'static) {
+    pub fn init_l10n(&self, localize: impl Fn([&'static str; 3], Command, &'static str, CommandMetaVar<Txt>) + Send + Sync + 'static) {
         let mut sv = EVENTS_SV.write();
         match &mut sv.l10n {
             EventsL10n::Pending(a) => {
diff --git a/crates/zng-ext-clipboard/l10n/template/_.ftl b/crates/zng-ext-clipboard/l10n/template/_.ftl
new file mode 100644
index 000000000..fcea2fd61
--- /dev/null
+++ b/crates/zng-ext-clipboard/l10n/template/_.ftl
@@ -0,0 +1,11 @@
+COPY_CMD =
+    .info = Place a copy of the selection in the clipboard.
+    .name = Copy
+
+CUT_CMD =
+    .info = Remove the selection and place it in the clipboard.
+    .name = Cut
+
+PASTE_CMD =
+    .info = Insert content from the clipboard.
+    .name = Paste
diff --git a/crates/zng-ext-fs-watcher/src/lib.rs b/crates/zng-ext-fs-watcher/src/lib.rs
index 57da337e8..e0d4354c8 100644
--- a/crates/zng-ext-fs-watcher/src/lib.rs
+++ b/crates/zng-ext-fs-watcher/src/lib.rs
@@ -177,7 +177,8 @@ impl WATCHER {
     /// Same operation as [`read`] but also tracks the operation status in a second var.
     ///
     /// The status variable is set to [`WatcherReadStatus::reading`] as soon as `read` starts and
-    /// is set to [`WatcherReadStatus::idle`] only when it updates with new read value.
+    /// is set to [`WatcherReadStatus::idle`] when read returns. If read returns a value the status
+    /// only updates to idle  when the new value is available on the var, or because read the same value.
     ///
     /// [`read`]: Self::read
     pub fn read_status<O, S, E>(
@@ -217,7 +218,8 @@ impl WATCHER {
     /// Same operation as [`read_dir`] but also tracks the operation status in a second var.
     ///
     /// The status variable is set to [`WatcherReadStatus::reading`] as soon as `read` starts and
-    /// is set to [`WatcherReadStatus::idle`] only when it updates with new read value.
+    /// is set to [`WatcherReadStatus::idle`] when read returns. If read returns a value the status
+    /// only updates to idle when the new value is available on the var, or because read the same value.
     ///
     /// [`read_dir`]: Self::read_dir
     pub fn read_dir_status<O, S, E>(
@@ -299,6 +301,10 @@ impl WATCHER {
 
     /// Same operation as [`sync`] but also tracks the operation status in a second var.
     ///
+    /// The status variable is set to [`WatcherReadStatus::reading`] as soon as `read` starts and
+    /// is set to [`WatcherReadStatus::idle`] when read returns. If read returns a value the status
+    /// only updates to idle when the new sync value is available, or because read the same value.
+    ///
     /// The status variable is set to [`WatcherSyncStatus::writing`] as soon as it updates and
     /// is set to [`WatcherReadStatus::idle`] only when the new sync value is available, either
     /// by update or because read the same value.
diff --git a/crates/zng-ext-hot-reload/src/lib.rs b/crates/zng-ext-hot-reload/src/lib.rs
index 988e426f0..e42785d62 100644
--- a/crates/zng-ext-hot-reload/src/lib.rs
+++ b/crates/zng-ext-hot-reload/src/lib.rs
@@ -665,7 +665,7 @@ impl fmt::Debug for HotLib {
 impl HotLib {
     pub fn new(patch: &StaticPatch, manifest_dir: Txt, lib: impl AsRef<std::ffi::OsStr>) -> Result<Self, libloading::Error> {
         unsafe {
-            // SAFETY: assuming the the hot lib was setup as the documented, this works,
+            // SAFETY: assuming the hot lib was setup as the documented, this works,
             // even the `linkme` stuff does not require any special care.
             //
             // If the hot lib developer add some "ctor/dtor" stuff and that fails they will probably
diff --git a/crates/zng-ext-input/l10n/template/_.ftl b/crates/zng-ext-input/l10n/template/_.ftl
new file mode 100644
index 000000000..de085bc94
--- /dev/null
+++ b/crates/zng-ext-input/l10n/template/_.ftl
@@ -0,0 +1,35 @@
+FOCUS_ALT_CMD =
+    .info = Focus alt scope.
+    .name = Focus Alt
+
+FOCUS_DOWN_CMD =
+    .info = Focus closest focusable down.
+    .name = Focus Down
+
+FOCUS_ENTER_CMD =
+    .info = Focus child focusable.
+    .name = Focus Enter
+
+FOCUS_EXIT_CMD =
+    .info = Focus parent focusable, or return focus.
+    .name = Focus Exit
+
+FOCUS_LEFT_CMD =
+    .info = Focus closest focusable left.
+    .name = Focus Left
+
+FOCUS_NEXT_CMD =
+    .info = Focus next focusable.
+    .name = Focus Next
+
+FOCUS_PREV_CMD =
+    .info = Focus previous focusable.
+    .name = Focus Previous
+
+FOCUS_RIGHT_CMD =
+    .info = Focus closest focusable right.
+    .name = Focus Right
+
+FOCUS_UP_CMD =
+    .info = Focus closest focusable up.
+    .name = Focus Up
diff --git a/crates/zng-ext-l10n-proc-macros/src/l10n.rs b/crates/zng-ext-l10n-proc-macros/src/l10n.rs
index 0100f6fa9..ce55f7fa6 100644
--- a/crates/zng-ext-l10n-proc-macros/src/l10n.rs
+++ b/crates/zng-ext-l10n-proc-macros/src/l10n.rs
@@ -85,7 +85,7 @@ pub fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
         let span = input.message.span();
 
         let mut build = quote_spanned! {span=>
-            #l10n_path::L10N.l10n_message(#message_params, #message)
+            #l10n_path::L10N.l10n_message(env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), #message_params, #message)
         };
         for var in variables {
             let var_ident = ident_spanned!(span=> "{}", var);
diff --git a/crates/zng-ext-l10n/Cargo.toml b/crates/zng-ext-l10n/Cargo.toml
index 6759ee398..1bc8d4213 100644
--- a/crates/zng-ext-l10n/Cargo.toml
+++ b/crates/zng-ext-l10n/Cargo.toml
@@ -23,6 +23,7 @@ zng-view-api = { path = "../zng-view-api", version = "0.5.4" }
 zng-layout = { path = "../zng-layout", version = "0.2.15" }
 zng-task = { path = "../zng-task", version = "0.3.3" }
 zng-ext-fs-watcher = { path = "../zng-ext-fs-watcher", version = "0.2.19" }
+zng-env = { path = "../zng-env", version = "0.3.0" }
 
 fluent = "0.16"
 fluent-syntax = "0.11"
@@ -33,3 +34,4 @@ tracing = "0.1"
 unicase = "2.7"
 once_cell = "1.19"
 dunce = "1.0"
+semver = "1.0"
\ No newline at end of file
diff --git a/crates/zng-ext-l10n/src/lib.rs b/crates/zng-ext-l10n/src/lib.rs
index efb952814..f4c7df2ba 100644
--- a/crates/zng-ext-l10n/src/lib.rs
+++ b/crates/zng-ext-l10n/src/lib.rs
@@ -11,6 +11,7 @@
 
 use std::{collections::HashMap, path::PathBuf, sync::Arc};
 
+use semver::Version;
 use zng_app::{
     event::{Command, CommandMetaVar, EVENTS_L10N},
     update::EventUpdate,
@@ -56,7 +57,19 @@ pub struct L10nManager {}
 impl AppExtension for L10nManager {
     fn init(&mut self) {
         EVENTS_L10N.init_l10n(|file, cmd, attr, txt| {
-            L10N.bind_command_meta(file, cmd, attr, txt);
+            L10N.bind_command_meta(
+                LangFilePath {
+                    pkg_name: file[0].into(),
+                    pkg_version: file[1].parse().unwrap_or_else(|e| {
+                        tracing::error!("invalid package version from command localization, {e}");
+                        Version::new(0, 0, 0)
+                    }),
+                    file: file[2].into(),
+                },
+                cmd,
+                attr,
+                txt,
+            );
         });
     }
 
@@ -114,7 +127,12 @@ impl AppExtension for L10nManager {
 ///     .attr = message
 /// ```
 ///
-/// And a key `"id.attr"` will be searched in the file `{dir}/{lang}.ftl`.
+/// And a key `"id.attr"` will be searched in the file `{dir}/{lang}/_.ftl`.
+///
+/// ### Package
+///
+/// The crate package name and version are also implicitly collected, when the message is requested from a different crate
+/// it is searched in `{dir}/{lang}/{pkg-name}/{pkg-version}/{file}.ftl`. Version matches any other version, the nearest is selected.
 ///
 /// # Scrap Template
 ///
@@ -205,7 +223,7 @@ impl L10N {
         L10N_SV.write().load(source);
     }
 
-    /// Start watching the `dir` for `"dir/{lang}.ftl"` and "dir/{lang}/*.ftl" files.
+    /// Start watching the `dir` for `dir/{lang}/*.ftl` and `dir/{lang}/deps/*/*/*.ftl` files.
     ///
     /// The [`available_langs`] variable maintains an up-to-date list of locale files found, the files
     /// are only loaded when needed, and also are watched to update automatically.
@@ -217,11 +235,12 @@ impl L10N {
 
     /// Available localization files.
     ///
-    /// The value maps lang to one or more files, the files can be `{dir}/{lang}.flt` or `{dir}/{lang}/*.flt`.
+    /// The value maps lang to one or more files, the files can be from the project `dir/{lang}/{file}.ftl` or from dependencies
+    /// `dir/{lang}/deps/{pkg-name/{pkg-version}/{file}.ftl`.
     ///
     /// Note that this map will include any file in the source dir that has a name that is a valid [`lang!`],
-    /// that includes the `template.flt` file and test pseudo-locales such as `qps-ploc.flt`.
-    pub fn available_langs(&self) -> BoxedVar<Arc<LangMap<HashMap<Txt, PathBuf>>>> {
+    /// that includes the `template.ftl` file and test pseudo-locales such as `qps-ploc.ftl`.
+    pub fn available_langs(&self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
         L10N_SV.write().available_langs()
     }
 
@@ -284,17 +303,20 @@ impl L10N {
     ///
     /// # Params
     ///
-    /// * `file`: Name of the resource file, in the default directory layout the file is searched at `{dir}/{lang}/{file}.flt`, if
-    ///           empty the file is searched at `{dir}/{lang}.flt`. Only a single file name is valid, no other path components allowed.
+    /// * `file`: Name of the resource file, in the default directory layout the file is searched at `dir/{lang}/{file}.ftl`, if
+    ///           empty the file is searched at `dir/{lang}/_.ftl`. Only a single file name is valid, no other path components allowed.
+    ///           Note that the file can also be a full [`LangFilePath`] that includes dependency package info. Those files are searched in
+    ///           `dir/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`.
     /// * `id`: Message identifier inside the resource file.
     /// * `attribute`: Attribute of the identifier, leave empty to not use an attribute.
+    /// * `fallback`: Message to use when a localized message cannot be found.
     ///
     /// The `id` and `attribute` is only valid if it starts with letter `[a-zA-Z]`, followed by any letters, digits, _ or - `[a-zA-Z0-9_-]*`.
     ///
     /// Panics if any parameter is invalid.
     pub fn message(
         &self,
-        file: impl Into<Txt>,
+        file: impl Into<LangFilePath>,
         id: impl Into<Txt>,
         attribute: impl Into<Txt>,
         fallback: impl Into<Txt>,
@@ -312,40 +334,25 @@ impl L10N {
     #[doc(hidden)]
     pub fn l10n_message(
         &self,
+        pkg_name: &'static str,
+        pkg_version: &'static str,
         file: &'static str,
         id: &'static str,
         attribute: &'static str,
         fallback: &'static str,
     ) -> L10nMessageBuilder {
         self.message(
-            Txt::from_static(file),
+            LangFilePath {
+                pkg_name: Txt::from_static(pkg_name),
+                pkg_version: pkg_version.parse().unwrap(),
+                file: Txt::from_static(file),
+            },
             Txt::from_static(id),
             Txt::from_static(attribute),
             Txt::from_static(fallback),
         )
     }
 
-    /// Gets a formatted message var localized to a given `lang`.
-    ///
-    /// The returned variable is read-only and will update when the backing resource changes and when the `args` variables change.
-    ///
-    /// The lang file resource is lazy loaded and stays in memory only when there are variables alive linked to it, each lang
-    /// in the list is matched to available resources if no match is available the `fallback` message is used. The variable
-    /// may temporary contain the `fallback` as lang resources are loaded asynchronous.
-    pub fn localized_message(
-        &self,
-        lang: impl Into<Langs>,
-        file: impl Into<Txt>,
-        id: impl Into<Txt>,
-        attribute: impl Into<Txt>,
-        fallback: impl Into<Txt>,
-        args: impl Into<Vec<(Txt, BoxedVar<L10nArgument>)>>,
-    ) -> BoxedVar<Txt> {
-        L10N_SV
-            .write()
-            .localized_message(lang.into(), file.into(), id.into(), attribute.into(), fallback.into(), args.into())
-    }
-
     /// Gets a handle to the lang file resource.
     ///
     /// The resource will be loaded and stay in memory until all clones of the handle are dropped, this
@@ -358,11 +365,13 @@ impl L10N {
     /// # Params
     ///
     /// * `lang`: Language identifier.
-    /// * `file`: Name of the resource file, in the default directory layout the file is searched at `{dir}/{lang}/{file}.flt`, if
-    ///           empty the file is searched at `{dir}/{lang}.flt`. Only a single file name is valid, no other path components allowed.
+    /// * `file`: Name of the resource file, in the default directory layout the file is searched at `dir/{lang}/{file}.ftl`, if
+    ///           empty the file is searched at `dir/{lang}/_.ftl`. Only a single file name is valid, no other path components allowed.
+    ///           Note that the file can also be a full [`LangFilePath`] that includes dependency package info. Those files are searched in
+    ///           `dir/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`.
     ///
     /// Panics if the file is invalid.
-    pub fn lang_resource(&self, lang: impl Into<Lang>, file: impl Into<Txt>) -> LangResource {
+    pub fn lang_resource(&self, lang: impl Into<Lang>, file: impl Into<LangFilePath>) -> LangResource {
         L10N_SV.write().lang_resource(lang.into(), file.into())
     }
 
@@ -371,14 +380,11 @@ impl L10N {
     /// This awaits for the available langs to load, then collect an awaits for all lang files.
     pub async fn wait_lang(&self, lang: impl Into<Lang>) -> LangResources {
         let lang = lang.into();
-        let base = self.lang_resource(lang.clone(), "");
-        base.wait().await;
-
-        let mut r = vec![base];
-        for (name, _) in self.available_langs().get().get(&lang).into_iter().flatten() {
-            r.push(self.lang_resource(lang.clone(), name.clone()));
+        let mut r = vec![];
+        for (file, _) in self.available_langs().get().get(&lang).into_iter().flatten() {
+            r.push(self.lang_resource(lang.clone(), file.clone()));
         }
-        for h in &r[1..] {
+        for h in &r {
             h.wait().await;
         }
         LangResources(r)
@@ -396,8 +402,8 @@ impl L10N {
         for lang in langs.0 {
             if let Some(files) = available.get_exact(&lang) {
                 let mut r = Vec::with_capacity(files.len());
-                for name in files.keys() {
-                    r.push(self.lang_resource(lang.clone(), name.clone()));
+                for file in files.keys() {
+                    r.push(self.lang_resource(lang.clone(), file.clone()));
                 }
                 let handle = LangResources(r);
                 handle.wait().await;
@@ -414,7 +420,13 @@ impl L10N {
     /// This is automatically called by [`command!`] instances that set the metadata `l10n!: true` or `l10n!: "file"`.
     ///
     /// [`command!`]: zng_app::event::command!
-    pub fn bind_command_meta(&self, file: impl Into<Txt>, cmd: Command, meta_name: impl Into<Txt>, meta_value: CommandMetaVar<Txt>) {
+    pub fn bind_command_meta(
+        &self,
+        file: impl Into<LangFilePath>,
+        cmd: Command,
+        meta_name: impl Into<Txt>,
+        meta_value: CommandMetaVar<Txt>,
+    ) {
         let msg = self.message(file, cmd.event().as_any().name(), meta_name, meta_value.get()).build();
         meta_value.set_from(&msg).unwrap();
 
@@ -468,18 +480,18 @@ macro_rules! lang {
 /// [`L10N.load`]: L10N::load
 pub trait L10nSource: Send + 'static {
     /// Gets a read-only variable with all lang files that the source can provide.
-    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<Txt, PathBuf>>>>;
+    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>;
     /// Gets a read-only variable that is the status of the [`available_langs`] value.
     ///
     /// [`available_langs`]: Self::available_langs
     fn available_langs_status(&mut self) -> BoxedVar<LangResourceStatus>;
 
     /// Gets a read-only variable that provides the fluent resource for the `lang` and `file` if available.
-    fn lang_resource(&mut self, lang: Lang, file: Txt) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>>;
+    fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>>;
     /// Gets a read-only variable that is the status of the [`lang_resource`] value.
     ///
     /// [`lang_resource`]: Self::lang_resource
-    fn lang_resource_status(&mut self, lang: Lang, file: Txt) -> BoxedVar<LangResourceStatus>;
+    fn lang_resource_status(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<LangResourceStatus>;
 }
 
 fn from_unic_char_direction(d: unic_langid::CharacterDirection) -> LayoutDirection {
diff --git a/crates/zng-ext-l10n/src/service.rs b/crates/zng-ext-l10n/src/service.rs
index fe000cb67..e9ea4db7e 100644
--- a/crates/zng-ext-l10n/src/service.rs
+++ b/crates/zng-ext-l10n/src/service.rs
@@ -15,7 +15,9 @@ use zng_var::{
 };
 use zng_view_api::config::LocaleConfig;
 
-use crate::{FluentParserErrors, L10nArgument, L10nSource, Lang, LangMap, LangResource, LangResourceStatus, Langs, SwapL10nSource};
+use crate::{
+    FluentParserErrors, L10nArgument, L10nSource, Lang, LangFilePath, LangMap, LangResource, LangResourceStatus, Langs, SwapL10nSource,
+};
 
 pub(super) struct L10nService {
     source: Mutex<SwapL10nSource>, // Mutex for `Sync` only.
@@ -23,7 +25,7 @@ pub(super) struct L10nService {
     app_lang: ArcCowVar<Langs, ArcVar<Langs>>,
 
     perm_res: Vec<BoxedVar<Option<ArcEq<fluent::FluentResource>>>>,
-    bundles: HashMap<(Langs, Txt), BoxedWeakVar<ArcFluentBundle>>,
+    bundles: HashMap<(Langs, LangFilePath), BoxedWeakVar<ArcFluentBundle>>,
 }
 impl L10nService {
     pub fn new() -> Self {
@@ -41,7 +43,7 @@ impl L10nService {
         self.source.get_mut().load(source);
     }
 
-    pub fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<Txt, PathBuf>>>> {
+    pub fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
         self.source.get_mut().available_langs()
     }
 
@@ -57,10 +59,11 @@ impl L10nService {
         self.app_lang.clone()
     }
 
+    #[allow(clippy::too_many_arguments)]
     pub fn localized_message(
         &mut self,
         langs: Langs,
-        file: Txt,
+        file: LangFilePath,
         id: Txt,
         attribute: Txt,
         fallback: Txt,
@@ -75,7 +78,7 @@ impl L10nService {
                 fluent_args_var(args)
                     .map(move |args| {
                         let args = args.lock();
-                        format_fallback(file.as_str(), id.as_str(), attribute.as_str(), &fallback, Some(&*args))
+                        format_fallback(&file.file, id.as_str(), attribute.as_str(), &fallback, Some(&*args))
                     })
                     .boxed()
             };
@@ -131,7 +134,7 @@ impl L10nService {
                         if !errors.is_empty() {
                             let e = FluentErrors(errors);
                             let key = DisplayKey {
-                                file: file.as_str(),
+                                file: &file.file,
                                 id: id.as_str(),
                                 attribute: attribute.as_str(),
                             };
@@ -141,7 +144,7 @@ impl L10nService {
                     }
                 }
 
-                format_fallback(file.as_str(), id.as_str(), attribute.as_str(), &fallback, Some(&args))
+                format_fallback(&file.file, id.as_str(), attribute.as_str(), &fallback, Some(&args))
             })
             .boxed()
         } else {
@@ -161,7 +164,7 @@ impl L10nService {
                         if !errors.is_empty() {
                             let e = FluentErrors(errors);
                             let key = DisplayKey {
-                                file: file.as_str(),
+                                file: &file.file,
                                 id: id.as_str(),
                                 attribute: attribute.as_str(),
                             };
@@ -172,13 +175,13 @@ impl L10nService {
                 }
 
                 let args = args.lock();
-                format_fallback(file.as_str(), id.as_str(), attribute.as_str(), &fallback, Some(&*args))
+                format_fallback(&file.file, id.as_str(), attribute.as_str(), &fallback, Some(&*args))
             })
             .boxed()
         }
     }
 
-    fn resource_bundle(&mut self, langs: Langs, file: Txt) -> BoxedVar<ArcFluentBundle> {
+    fn resource_bundle(&mut self, langs: Langs, file: LangFilePath) -> BoxedVar<ArcFluentBundle> {
         match self.bundles.entry((langs, file)) {
             hash_map::Entry::Occupied(mut e) => {
                 if let Some(r) = e.get().upgrade() {
@@ -197,7 +200,7 @@ impl L10nService {
             }
         }
     }
-    fn new_resource_bundle(source: &mut SwapL10nSource, langs: &Langs, file: &Txt) -> BoxedVar<ArcFluentBundle> {
+    fn new_resource_bundle(source: &mut SwapL10nSource, langs: &Langs, file: &LangFilePath) -> BoxedVar<ArcFluentBundle> {
         if langs.len() == 1 {
             let lang = langs[0].clone();
             let res = source.lang_resource(lang.clone(), file.clone());
@@ -229,7 +232,7 @@ impl L10nService {
         }
     }
 
-    pub fn lang_resource(&mut self, lang: Lang, file: Txt) -> LangResource {
+    pub fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> LangResource {
         LangResource {
             res: self.source.get_mut().lang_resource(lang.clone(), file.clone()),
             status: self.source.get_mut().lang_resource_status(lang, file),
diff --git a/crates/zng-ext-l10n/src/sources.rs b/crates/zng-ext-l10n/src/sources.rs
index 6ff74c592..471359234 100644
--- a/crates/zng-ext-l10n/src/sources.rs
+++ b/crates/zng-ext-l10n/src/sources.rs
@@ -1,27 +1,21 @@
-use std::{
-    collections::HashMap,
-    io,
-    path::{Path, PathBuf},
-    str::FromStr,
-    sync::Arc,
-};
+use std::{collections::HashMap, io, path::PathBuf, str::FromStr, sync::Arc};
 
+use semver::Version;
 use zng_clone_move::clmv;
 use zng_ext_fs_watcher::WATCHER;
 use zng_txt::Txt;
 use zng_var::{types::WeakArcVar, var, AnyVar, ArcEq, ArcVar, BoxedVar, BoxedWeakVar, LocalVar, Var, VarHandle, WeakVar};
 
-use crate::{FluentParserErrors, L10nSource, Lang, LangMap, LangResourceStatus};
+use crate::{FluentParserErrors, L10nSource, Lang, LangFilePath, LangMap, LangResourceStatus};
 
 /// Represents localization resources synchronized from files in a directory.
 ///
-/// The expected directory layout is `{dir}/{lang}.flt` for lang only and `{dir}/{lang}/file.flt` for
-/// lang with file.
+/// The expected directory layout is `{dir}/{lang}/{file}.ftl` app files and `{dir}/{lang}/deps/{pkg-name}/{pkg-version}/{file}.ftl`
+/// for dependencies.
 pub struct L10nDir {
-    dir: PathBuf,
-    dir_watch: BoxedVar<Arc<LangMap<HashMap<Txt, PathBuf>>>>,
+    dir_watch: BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
     dir_watch_status: BoxedVar<LangResourceStatus>,
-    res: HashMap<(Lang, Txt), L10nFile>,
+    res: HashMap<(Lang, LangFilePath), L10nFile>,
 }
 impl L10nDir {
     /// Start watching the `dir` for localization files.
@@ -29,88 +23,100 @@ impl L10nDir {
         Self::new(dir.into())
     }
     fn new(dir: PathBuf) -> Self {
-        let status = var(LangResourceStatus::Loading);
-        let dir_watch = WATCHER.read_dir(
+        let (dir_watch, status) = WATCHER.read_dir_status(
             dir.clone(),
             true,
             Arc::default(),
-            clmv!(status, |d| {
-                status.set(LangResourceStatus::Loading);
-
-                let mut set: LangMap<HashMap<Txt, PathBuf>> = LangMap::new();
+            clmv!(|d| {
+                let mut set: LangMap<HashMap<LangFilePath, PathBuf>> = LangMap::new();
                 let mut errors: Vec<Arc<dyn std::error::Error + Send + Sync>> = vec![];
                 let mut dir = None;
-                for entry in d.min_depth(0).max_depth(1) {
-                    match entry {
-                        Ok(f) => {
-                            let ty = f.file_type();
-                            if dir.is_none() {
-                                // get the watched dir
-                                if !ty.is_dir() {
-                                    tracing::error!("L10N path not a directory");
-                                    status.set(LangResourceStatus::NotAvailable);
-                                    return None;
-                                }
-                                dir = Some(f.path().to_owned());
-                            }
+                for entry in d.min_depth(0).max_depth(5) {
+                    let entry = match entry {
+                        Ok(e) => e,
+                        Err(e) => {
+                            errors.push(Arc::new(e));
+                            continue;
+                        }
+                    };
+                    let ty = entry.file_type();
+
+                    if dir.is_none() {
+                        // get the watched dir (first because of min_depth(0))
+                        if !ty.is_dir() {
+                            tracing::error!("L10N path not a directory");
+                            return Err(LangResourceStatus::NotAvailable);
+                        }
+                        dir = Some(entry.path().to_owned());
+                        continue;
+                    }
 
-                            const EXT: unicase::Ascii<&'static str> = unicase::Ascii::new("ftl");
-
-                            if ty.is_file() {
-                                // match dir/lang.flt files
-                                if let Some(name_and_ext) = f.file_name().to_str() {
-                                    if let Some((name, ext)) = name_and_ext.rsplit_once('.') {
-                                        if ext.is_ascii() && unicase::Ascii::new(ext) == EXT {
-                                            tracing::debug!("found {name}.{ext}");
-                                            // found .flt file.
-                                            match Lang::from_str(name) {
-                                                Ok(lang) => {
-                                                    // and it is named correctly.
-                                                    set.get_exact_or_insert(lang, Default::default)
-                                                        .insert(Txt::from_str(""), dir.as_ref().unwrap().join(name_and_ext));
-                                                }
-                                                Err(e) => {
-                                                    errors.push(Arc::new(e));
-                                                }
-                                            }
-                                        }
-                                    }
-                                }
-                            } else if f.depth() == 1 && ty.is_dir() {
-                                // match dir/lang/file.flt files
-                                if let Some(dir_name) = f.file_name().to_str() {
-                                    match Lang::from_str(dir_name) {
-                                        Ok(lang) => {
-                                            let inner = set.get_exact_or_insert(lang, Default::default);
-                                            for entry in std::fs::read_dir(f.path()).into_iter().flatten() {
-                                                match entry {
-                                                    Ok(f) => {
-                                                        if let Ok(name_and_ext) = f.file_name().into_string() {
-                                                            if let Some((name, ext)) = name_and_ext.rsplit_once('.') {
-                                                                if ext.is_ascii() && unicase::Ascii::new(ext) == EXT {
-                                                                    // found .flt file.
-                                                                    tracing::debug!("found {dir_name}/{name}.{ext}");
-                                                                    inner.insert(Txt::from_str(name), f.path());
-                                                                }
-                                                            }
-                                                        }
-                                                    }
-                                                    Err(e) => errors.push(Arc::new(e)),
-                                                }
-                                            }
-                                            if inner.is_empty() {
-                                                set.pop();
-                                            }
-                                        }
-                                        Err(e) => {
-                                            tracing::trace!("dir {dir_name}/ is not l10n, {e}");
-                                        }
-                                    }
-                                }
+                    const EXT: unicase::Ascii<&'static str> = unicase::Ascii::new("ftl");
+
+                    let is_ftl = ty.is_file()
+                        && entry
+                            .file_name()
+                            .to_str()
+                            .and_then(|n| n.rsplit_once('.'))
+                            .map(|(_, ext)| ext.is_ascii() && unicase::Ascii::new(ext) == EXT)
+                            .unwrap_or(false);
+
+                    if !is_ftl {
+                        continue;
+                    }
+
+                    let mut utf8_path = [""; 5];
+                    for (i, part) in entry.path().iter().rev().take(entry.depth()).enumerate() {
+                        match part.to_str() {
+                            Some(p) => utf8_path[entry.depth() - i - 1] = p,
+                            None => continue,
+                        }
+                    }
+
+                    let (lang, mut file) = match entry.depth() {
+                        // lang/file.ftl
+                        2 => {
+                            let lang = utf8_path[0];
+                            let file = Txt::from_str(utf8_path[1].rsplit_once('.').unwrap().0);
+                            (lang, LangFilePath::current_app(file))
+                        }
+                        // lang/deps/pkg-name/pkg-version/file.ftl
+                        5 => {
+                            if utf8_path[1] != "deps" {
+                                continue;
                             }
+                            let lang = utf8_path[0];
+                            let pkg_name = Txt::from_str(utf8_path[2]);
+                            let pkg_version: Version = match utf8_path[3].parse() {
+                                Ok(v) => v,
+                                Err(e) => {
+                                    errors.push(Arc::new(e));
+                                    continue;
+                                }
+                            };
+                            let file = Txt::from_str(utf8_path[4]);
+
+                            (lang, LangFilePath::new(pkg_name, pkg_version, file))
                         }
-                        Err(e) => errors.push(Arc::new(e)),
+                        _ => {
+                            continue;
+                        }
+                    };
+
+                    let lang = match Lang::from_str(lang) {
+                        Ok(l) => l,
+                        Err(e) => {
+                            errors.push(Arc::new(e));
+                            continue;
+                        }
+                    };
+
+                    if file.file == "_" {
+                        file.file = "".into();
                     }
+
+                    set.get_exact_or_insert(lang, Default::default)
+                        .insert(file, entry.path().to_owned());
                 }
 
                 if errors.is_empty() {
@@ -118,16 +124,14 @@ impl L10nDir {
                 } else {
                     let s = LangResourceStatus::Errors(errors);
                     tracing::error!("'loading available' {s}");
-                    status.set(s)
+                    return Err(s);
                 }
 
-                Some(Arc::new(set))
+                Ok(Some(Arc::new(set)))
             }),
         );
-        dir_watch.bind_map(&status, |_| LangResourceStatus::Loaded).perm();
 
         Self {
-            dir,
             dir_watch: dir_watch.boxed(),
             dir_watch_status: status.read_only().boxed(),
             res: HashMap::new(),
@@ -135,21 +139,21 @@ impl L10nDir {
     }
 }
 impl L10nSource for L10nDir {
-    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<Txt, PathBuf>>>> {
+    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
         self.dir_watch.clone()
     }
     fn available_langs_status(&mut self) -> BoxedVar<LangResourceStatus> {
         self.dir_watch_status.clone()
     }
 
-    fn lang_resource(&mut self, lang: Lang, file: Txt) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
+    fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
         match self.res.entry((lang, file)) {
             std::collections::hash_map::Entry::Occupied(mut e) => {
                 if let Some(out) = e.get().res.upgrade() {
                     out
                 } else {
                     let (lang, file) = e.key();
-                    let out = load_file(e.get().status.clone(), &self.dir, lang, file);
+                    let out = resource_var(&self.dir_watch, e.get().status.clone(), lang.clone(), file.clone());
                     e.get_mut().res = out.downgrade();
                     out
                 }
@@ -157,7 +161,7 @@ impl L10nSource for L10nDir {
             std::collections::hash_map::Entry::Vacant(e) => {
                 let mut f = L10nFile::new();
                 let (lang, file) = e.key();
-                let out = load_file(f.status.clone(), &self.dir, lang, file);
+                let out = resource_var(&self.dir_watch, f.status.clone(), lang.clone(), file.clone());
                 f.res = out.downgrade();
                 e.insert(f);
                 out
@@ -165,7 +169,7 @@ impl L10nSource for L10nDir {
         }
     }
 
-    fn lang_resource_status(&mut self, lang: Lang, file: Txt) -> BoxedVar<LangResourceStatus> {
+    fn lang_resource_status(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<LangResourceStatus> {
         self.res
             .entry((lang, file))
             .or_insert_with(L10nFile::new)
@@ -186,49 +190,56 @@ impl L10nFile {
         }
     }
 }
-fn load_file(status: ArcVar<LangResourceStatus>, dir: &Path, lang: &Lang, file: &Txt) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
-    status.set(LangResourceStatus::Loading);
-
-    let path = if file.is_empty() {
-        format!("{lang}.ftl")
-    } else {
-        format!("{lang}/{file}.ftl")
-    };
-
-    let r = WATCHER.read(
-        dir.join(path),
-        None,
-        clmv!(status, |file| {
-            status.set(LangResourceStatus::Loading);
-
-            match file.and_then(|mut f| f.string()) {
-                Ok(flt) => match fluent::FluentResource::try_new(flt) {
-                    Ok(flt) => {
-                        // ok
-                        // Loaded set by `r` to avoid race condition in waiter.
-                        return Some(Some(ArcEq::new(flt)));
-                    }
-                    Err(e) => {
-                        let e = FluentParserErrors(e.1);
-                        tracing::error!("error parsing fluent resource, {e}");
-                        status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
-                    }
-                },
-                Err(e) => {
-                    if matches!(e.kind(), io::ErrorKind::NotFound) {
-                        status.set(LangResourceStatus::NotAvailable);
-                    } else {
-                        tracing::error!("error loading fluent resource, {e}");
-                        status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
-                    }
-                }
+
+fn resource_var(
+    dir_watch: &BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
+    status: ArcVar<LangResourceStatus>,
+    lang: Lang,
+    file: LangFilePath,
+) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
+    dir_watch
+        .map(move |w| w.get_file(&lang, &file).cloned())
+        .flat_map(move |p| match p {
+            Some(p) => {
+                status.set(LangResourceStatus::Loading);
+
+                let r = WATCHER.read(
+                    p.clone(),
+                    None,
+                    clmv!(status, |file| {
+                        status.set(LangResourceStatus::Loading);
+
+                        match file.and_then(|mut f| f.string()) {
+                            Ok(flt) => match fluent::FluentResource::try_new(flt) {
+                                Ok(flt) => {
+                                    // ok
+                                    // Loaded set by `r` to avoid race condition in waiter.
+                                    return Some(Some(ArcEq::new(flt)));
+                                }
+                                Err(e) => {
+                                    let e = FluentParserErrors(e.1);
+                                    tracing::error!("error parsing fluent resource, {e}");
+                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
+                                }
+                            },
+                            Err(e) => {
+                                if matches!(e.kind(), io::ErrorKind::NotFound) {
+                                    status.set(LangResourceStatus::NotAvailable);
+                                } else {
+                                    tracing::error!("error loading fluent resource, {e}");
+                                    status.set(LangResourceStatus::Errors(vec![Arc::new(e)]));
+                                }
+                            }
+                        }
+                        // not ok
+                        Some(None)
+                    }),
+                );
+                r.bind_map(&status, |_| LangResourceStatus::Loaded).perm();
+                r.boxed()
             }
-            // not ok
-            Some(None)
-        }),
-    );
-    r.bind_map(&status, |_| LangResourceStatus::Loaded).perm();
-    r.boxed()
+            None => LocalVar(None).boxed(),
+        })
 }
 
 /// Represents localization source that can swap the actual source without disconnecting variables
@@ -240,10 +251,10 @@ fn load_file(status: ArcVar<LangResourceStatus>, dir: &Path, lang: &Lang, file:
 pub struct SwapL10nSource {
     actual: Box<dyn L10nSource>,
 
-    available_langs: ArcVar<Arc<LangMap<HashMap<Txt, PathBuf>>>>,
+    available_langs: ArcVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>>,
     available_langs_status: ArcVar<LangResourceStatus>,
 
-    res: HashMap<(Lang, Txt), SwapFile>,
+    res: HashMap<(Lang, LangFilePath), SwapFile>,
 }
 impl SwapL10nSource {
     /// New with [`NilL10nSource`].
@@ -296,7 +307,7 @@ impl Default for SwapL10nSource {
     }
 }
 impl L10nSource for SwapL10nSource {
-    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<Txt, PathBuf>>>> {
+    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
         self.available_langs.read_only().boxed()
     }
 
@@ -304,7 +315,7 @@ impl L10nSource for SwapL10nSource {
         self.available_langs_status.read_only().boxed()
     }
 
-    fn lang_resource(&mut self, lang: Lang, file: Txt) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
+    fn lang_resource(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
         match self.res.entry((lang, file)) {
             std::collections::hash_map::Entry::Occupied(mut e) => {
                 if let Some(res) = e.get().res.upgrade() {
@@ -358,7 +369,7 @@ impl L10nSource for SwapL10nSource {
         }
     }
 
-    fn lang_resource_status(&mut self, lang: Lang, file: Txt) -> BoxedVar<LangResourceStatus> {
+    fn lang_resource_status(&mut self, lang: Lang, file: LangFilePath) -> BoxedVar<LangResourceStatus> {
         self.res
             .entry((lang, file))
             .or_insert_with(SwapFile::new)
@@ -389,7 +400,7 @@ impl SwapFile {
 /// Localization source that is never available.
 pub struct NilL10nSource;
 impl L10nSource for NilL10nSource {
-    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<Txt, PathBuf>>>> {
+    fn available_langs(&mut self) -> BoxedVar<Arc<LangMap<HashMap<LangFilePath, PathBuf>>>> {
         LocalVar(Arc::default()).boxed()
     }
 
@@ -397,11 +408,11 @@ impl L10nSource for NilL10nSource {
         LocalVar(LangResourceStatus::NotAvailable).boxed()
     }
 
-    fn lang_resource(&mut self, _: Lang, _: Txt) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
+    fn lang_resource(&mut self, _: Lang, _: LangFilePath) -> BoxedVar<Option<ArcEq<fluent::FluentResource>>> {
         LocalVar(None).boxed()
     }
 
-    fn lang_resource_status(&mut self, _: Lang, _: Txt) -> BoxedVar<LangResourceStatus> {
+    fn lang_resource_status(&mut self, _: Lang, _: LangFilePath) -> BoxedVar<LangResourceStatus> {
         LocalVar(LangResourceStatus::NotAvailable).boxed()
     }
 }
diff --git a/crates/zng-ext-l10n/src/types.rs b/crates/zng-ext-l10n/src/types.rs
index 8267a8114..1191b40be 100644
--- a/crates/zng-ext-l10n/src/types.rs
+++ b/crates/zng-ext-l10n/src/types.rs
@@ -1,7 +1,8 @@
-use std::{borrow::Cow, fmt, mem, ops, sync::Arc};
+use std::{borrow::Cow, collections::HashMap, fmt, mem, ops, path::PathBuf, sync::Arc};
 
 use fluent::types::FluentNumber;
 use once_cell::sync::Lazy;
+use semver::Version;
 use zng_ext_fs_watcher::WatcherReadStatus;
 use zng_layout::context::LayoutDirection;
 use zng_txt::Txt;
@@ -135,6 +136,19 @@ impl WatcherReadStatus<StatusError> for LangResourceStatus {
         Self::Errors(e)
     }
 }
+impl WatcherReadStatus<LangResourceStatus> for LangResourceStatus {
+    fn idle() -> Self {
+        Self::Loaded
+    }
+
+    fn reading() -> Self {
+        Self::Loading
+    }
+
+    fn read_error(e: LangResourceStatus) -> Self {
+        e
+    }
+}
 
 type StatusError = Vec<Arc<dyn std::error::Error + Send + Sync>>;
 
@@ -144,7 +158,7 @@ type StatusError = Vec<Arc<dyn std::error::Error + Send + Sync>>;
 ///
 /// [`L10N.message`]: L10N::message
 pub struct L10nMessageBuilder {
-    pub(super) file: Txt,
+    pub(super) file: LangFilePath,
     pub(super) id: Txt,
     pub(super) attribute: Txt,
     pub(super) fallback: Txt,
@@ -161,7 +175,14 @@ impl L10nMessageBuilder {
         self.arg(Txt::from_static(name), value)
     }
 
-    /// Build the variable.
+    /// Build the message var for the given languages.
+    pub fn build_for(self, lang: impl Into<Langs>) -> impl Var<Txt> {
+        L10N_SV
+            .write()
+            .localized_message(lang.into(), self.file, self.id, self.attribute, self.fallback, self.args)
+    }
+
+    /// Build the message var for the contextual language.
     pub fn build(self) -> impl Var<Txt> {
         let Self {
             file,
@@ -613,6 +634,39 @@ impl<V> LangMap<V> {
         self.inner.iter_mut().map(|(k, v)| (&*k, v))
     }
 }
+impl<V> LangMap<HashMap<LangFilePath, V>> {
+    /// Returns the match for `lang` and `file`.
+    pub fn get_file(&self, lang: &Lang, file: &LangFilePath) -> Option<&V> {
+        let files = self.get(lang)?;
+        if let Some(exact) = files.get(file) {
+            return Some(exact);
+        }
+        Self::best_file(files, file).map(|(_, v)| v)
+    }
+
+    /// Returns the best match to `lang` and `file` currently in the map.
+    pub fn best_file_match(&self, lang: &Lang, file: &LangFilePath) -> Option<&LangFilePath> {
+        let files = self.get(lang)?;
+        if let Some((exact, _)) = files.get_key_value(file) {
+            return Some(exact);
+        }
+        Self::best_file(files, file).map(|(k, _)| k)
+    }
+
+    fn best_file<'a>(files: &'a HashMap<LangFilePath, V>, file: &LangFilePath) -> Option<(&'a LangFilePath, &'a V)> {
+        let mut best = None;
+        let mut best_dist = u64::MAX;
+        for (k, v) in files {
+            if let Some(d) = k.matches(file) {
+                if d < best_dist {
+                    best = Some((k, v));
+                    best_dist = d;
+                }
+            }
+        }
+        best
+    }
+}
 impl<V> IntoIterator for LangMap<V> {
     type Item = (Lang, V);
 
@@ -659,3 +713,190 @@ impl std::error::Error for FluentParserErrors {
         }
     }
 }
+
+/// Localization resource file path in the localization directory.
+///
+/// In the default directory layout, localization dependencies are collected using `cargo zng l10n --deps`
+/// and copied to `l10n/{lang}/deps/{name}/{version}/`.
+#[derive(Debug, Clone)]
+pub struct LangFilePath {
+    /// Package name.
+    pub pkg_name: Txt,
+    /// Package version.
+    pub pkg_version: Version,
+    /// The file name.
+    pub file: Txt,
+}
+impl Ord for LangFilePath {
+    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+        match self.pkg_name.cmp(&other.pkg_name) {
+            core::cmp::Ordering::Equal => {}
+            ord => return ord,
+        }
+        match self.pkg_version.cmp(&other.pkg_version) {
+            core::cmp::Ordering::Equal => {}
+            ord => return ord,
+        }
+        self.file().cmp(&other.file())
+    }
+}
+impl PartialOrd for LangFilePath {
+    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+impl std::hash::Hash for LangFilePath {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.pkg_name.hash(state);
+        self.pkg_version.hash(state);
+        self.file().hash(state);
+    }
+}
+impl Eq for LangFilePath {}
+impl PartialEq for LangFilePath {
+    fn eq(&self, other: &Self) -> bool {
+        self.pkg_name == other.pkg_name && self.pkg_version == other.pkg_version && self.file() == other.file()
+    }
+}
+impl LangFilePath {
+    /// New from package name, version and file.
+    pub fn new(pkg_name: impl Into<Txt>, pkg_version: Version, file: impl Into<Txt>) -> Self {
+        Self {
+            pkg_name: pkg_name.into(),
+            pkg_version,
+            file: file.into(),
+        }
+    }
+
+    /// Gets a file in the current app.
+    ///
+    /// This value indicates that the localization resources are directly on the `l10n/{lang?}/` directories, not
+    /// in the dependencies directories.
+    ///
+    /// See [`zng_env::about()`] for more details.
+    pub fn current_app(file: impl Into<Txt>) -> LangFilePath {
+        let about = zng_env::about();
+        Self::new(about.pkg_name.clone(), about.version.clone(), file.into())
+    }
+
+    /// Gets if this file is in the [`current_app`] resources.
+    ///
+    /// [`current_app`]: Self::current_app
+    pub fn is_current_app(&self) -> bool {
+        self.pkg_name.is_empty() || {
+            let about = zng_env::about();
+            self.pkg_name == about.pkg_name && self.pkg_version == about.version
+        }
+    }
+
+    /// Gets the normalized file name.
+    pub fn file(&self) -> Txt {
+        if self.file.is_empty() {
+            Txt::from_char('_')
+        } else {
+            self.file.clone()
+        }
+    }
+
+    /// Get the file path, relative to the localization dir.
+    ///
+    /// * Empty file name is the same as `_`.
+    /// * If package [`is_current_app`] gets `{lang}/{file}.ftl`.
+    /// * Else if is another package gets `{lang}/deps/{pkg_name}/{pkg_version}/{file}.ftl`.
+    ///
+    /// [`is_current_app`]: Self::is_current_app
+    pub fn to_path(&self, lang: &Lang) -> PathBuf {
+        let mut file = self.file.as_str();
+        if file.is_empty() {
+            file = "_";
+        }
+        if self.is_current_app() {
+            format!("{lang}/{file}.ftl")
+        } else {
+            format!("{lang}/deps/{}/{}/{file}.ftl", self.pkg_name, self.pkg_version)
+        }
+        .into()
+    }
+
+    /// Gets a value that indicates if the resources represented by `self` can be used for `search`.
+    ///
+    /// The number indicates the quality of the match:
+    ///
+    /// * `0` is an exact match.
+    /// * `b1` is a match with only version `build` differences.
+    /// * `b10` is a match with only version `pre` differences.
+    /// * `(0..u16::MAX) << 16` is a match with only `patch` differences and the absolute distance.
+    /// * `(0..u16::MAX) << 16 * 2` is a match with `minor` differences and the absolute distance.
+    /// * `(0..u16::MAX) << 16 * 3` is a match with `major` differences and the absolute distance.
+    /// * `None`` is a `pkg_name` mismatch.
+    pub fn matches(&self, search: &Self) -> Option<u64> {
+        if self.pkg_name != search.pkg_name {
+            return None;
+        }
+
+        fn dist(a: u64, b: u64, shift: u64) -> u64 {
+            let (l, s) = match a.cmp(&b) {
+                std::cmp::Ordering::Equal => return 0,
+                std::cmp::Ordering::Less => (b, a),
+                std::cmp::Ordering::Greater => (a, b),
+            };
+
+            (l - s).min(u16::MAX as u64) << (16 * shift)
+        }
+
+        let mut d = 0;
+        if self.pkg_version.build != search.pkg_version.build {
+            d = 1;
+        }
+        if self.pkg_version.pre != search.pkg_version.pre {
+            d |= 0b10;
+        }
+
+        d |= dist(self.pkg_version.patch, search.pkg_version.patch, 1);
+        d |= dist(self.pkg_version.minor, search.pkg_version.minor, 2);
+        d |= dist(self.pkg_version.major, search.pkg_version.major, 3);
+
+        Some(d)
+    }
+}
+impl_from_and_into_var! {
+    fn from(file: Txt) -> LangFilePath {
+        LangFilePath::current_app(file)
+    }
+
+    fn from(file: &'static str) -> LangFilePath {
+        LangFilePath::current_app(file)
+    }
+
+    fn from(file: String) -> LangFilePath {
+        LangFilePath::current_app(file)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn file_matches() {
+        fn check(a: &str, b: &str, c: &str) {
+            let ap = LangFilePath::new("name", a.parse().unwrap(), "file");
+            let bp = LangFilePath::new("name", b.parse().unwrap(), "file");
+            let cp = LangFilePath::new("name", c.parse().unwrap(), "file");
+
+            let ab = ap.matches(&bp);
+            let ac = ap.matches(&cp);
+
+            assert!(ab < ac, "expected {a}.matches({b}) < {a}.matches({c})")
+        }
+
+        check("0.0.0", "0.0.1", "0.1.0");
+        check("0.0.1", "0.1.0", "1.0.0");
+        check("0.0.0-pre", "0.0.0-pre+build", "0.0.0-other+build");
+        check("0.0.0+build", "0.0.0+build", "0.0.0+other");
+        check("0.0.1", "0.0.2", "0.0.3");
+        check("0.1.0", "0.2.0", "0.3.0");
+        check("1.0.0", "2.0.0", "3.0.0");
+        check("1.0.0", "1.1.0", "2.0.0");
+    }
+}
diff --git a/crates/zng-ext-undo/l10n/template/_.ftl b/crates/zng-ext-undo/l10n/template/_.ftl
new file mode 100644
index 000000000..c2cd2652d
--- /dev/null
+++ b/crates/zng-ext-undo/l10n/template/_.ftl
@@ -0,0 +1,8 @@
+CLEAR_HISTORY_CMD =
+    .name = Clear History
+
+REDO_CMD =
+    .name = Redo
+
+UNDO_CMD =
+    .name = Undo
diff --git a/crates/zng-ext-window/l10n/template/_.ftl b/crates/zng-ext-window/l10n/template/_.ftl
new file mode 100644
index 000000000..8cc393ac5
--- /dev/null
+++ b/crates/zng-ext-window/l10n/template/_.ftl
@@ -0,0 +1,23 @@
+CLOSE_CMD =
+    .info = Close the window.
+    .name = Close
+
+EXCLUSIVE_FULLSCREEN_CMD =
+    .info = Toggle exclusive full-screen mode on the window.
+    .name = Exclusive Full-Screen
+
+FULLSCREEN_CMD =
+    .info = Toggle full-screen mode on the window.
+    .name = Full-Screen
+
+MAXIMIZE_CMD =
+    .info = Maximize the window.
+    .name = Maximize
+
+MINIMIZE_CMD =
+    .info = Minimize the window.
+    .name = Minimize
+
+RESTORE_CMD =
+    .info = Restores the window to its previous non-minimized state or normal state.
+    .name = Restore
diff --git a/crates/zng-ext-window/src/vars.rs b/crates/zng-ext-window/src/vars.rs
index 7ba7e36ba..e82ab7d15 100644
--- a/crates/zng-ext-window/src/vars.rs
+++ b/crates/zng-ext-window/src/vars.rs
@@ -717,7 +717,7 @@ impl WindowVars {
     /// The current `zng::view_process` or `zng-view` only implements this feature on Windows and it will only work properly
     /// under these conditions:
     ///
-    /// * It must be running in `run_same_process` mode. Windows kills all other processes, so in a run with `init` the the app-process
+    /// * It must be running in `run_same_process` mode. Windows kills all other processes, so in a run with `init` the app-process
     /// will be lost. Note that this also mean that the crash handler and worker processes are also killed.
     /// * Must be built with `#![windows_subsystem = "windows"]` and must be running from the Windows Explorer (desktop).
     pub fn system_shutdown_warn(&self) -> ArcVar<Txt> {
diff --git a/crates/zng-wgt-input/l10n/template/_.ftl b/crates/zng-wgt-input/l10n/template/_.ftl
new file mode 100644
index 000000000..f0a45e1c5
--- /dev/null
+++ b/crates/zng-wgt-input/l10n/template/_.ftl
@@ -0,0 +1,14 @@
+NEW_CMD =
+    .name = New
+
+OPEN_CMD =
+    .name = Open…
+
+SAVE_AS_CMD =
+    .name = Save As…
+
+SAVE_CMD =
+    .name = Save
+
+SETTINGS_CMD =
+    .name = Settings
diff --git a/crates/zng-wgt-inspector/l10n/template/_.ftl b/crates/zng-wgt-inspector/l10n/template/_.ftl
new file mode 100644
index 000000000..49e89c151
--- /dev/null
+++ b/crates/zng-wgt-inspector/l10n/template/_.ftl
@@ -0,0 +1,3 @@
+INSPECT_CMD =
+    .info = Inspect the window.
+    .name = Debug Inspector
diff --git a/crates/zng-wgt-scroll/l10n/template/_.ftl b/crates/zng-wgt-scroll/l10n/template/_.ftl
new file mode 100644
index 000000000..517ffd87e
--- /dev/null
+++ b/crates/zng-wgt-scroll/l10n/template/_.ftl
@@ -0,0 +1,59 @@
+PAGE_DOWN_CMD =
+    .info = Scroll down by one page unit.
+    .name = Page Down
+
+PAGE_LEFT_CMD =
+    .info = Scroll Left by one page unit.
+    .name = Page Left
+
+PAGE_RIGHT_CMD =
+    .info = Scroll Right by one page unit.
+    .name = Page Right
+
+PAGE_UP_CMD =
+    .info = Scroll Up by one page unit.
+    .name = Page Up
+
+SCROLL_DOWN_CMD =
+    .info = Scroll Down by one scroll unit.
+    .name = Scroll Down
+
+SCROLL_LEFT_CMD =
+    .info = Scroll Left by one scroll unit.
+    .name = Scroll Left
+
+SCROLL_RIGHT_CMD =
+    .info = Scroll Right by one scroll unit.
+    .name = Scroll Right
+
+SCROLL_TO_BOTTOM_CMD =
+    .info = Scroll down to the content bottom.
+    .name = Scroll to Bottom
+
+SCROLL_TO_LEFTMOST_CMD =
+    .info = Scroll left to the content left edge.
+    .name = Scroll to Leftmost
+
+SCROLL_TO_RIGHTMOST_CMD =
+    .info = Scroll right to the content right edge.
+    .name = Scroll to Rightmost
+
+SCROLL_TO_TOP_CMD =
+    .info = Scroll up to the content top.
+    .name = Scroll to Top
+
+SCROLL_UP_CMD =
+    .info = Scroll Up by one scroll unit.
+    .name = Scroll Up
+
+ZOOM_IN_CMD =
+    .name = Zoom In
+
+ZOOM_OUT_CMD =
+    .name = Zoom Out
+
+ZOOM_RESET_CMD =
+    .name = Reset Zoom
+
+ZOOM_TO_FIT_CMD =
+    .name = Zoom to Fit
diff --git a/crates/zng-wgt-text-input/src/text_input.rs b/crates/zng-wgt-text-input/src/text_input.rs
index 125b420cb..b996e9737 100644
--- a/crates/zng-wgt-text-input/src/text_input.rs
+++ b/crates/zng-wgt-text-input/src/text_input.rs
@@ -229,7 +229,7 @@ impl SearchStyle {
     }
 }
 
-/// Text shown when the the `txt` is empty.
+/// Text shown when the `txt` is empty.
 ///
 /// The placeholder has the same text style as the parent widget, with 50% opacity.
 /// You can use the [`placeholder`](fn@placeholder) to use a custom widget placeholder.
@@ -245,7 +245,7 @@ pub fn placeholder_txt(child: impl UiNode, txt: impl IntoVar<Txt>) -> impl UiNod
     )
 }
 
-/// Widget shown when the the `txt` is empty.
+/// Widget shown when the `txt` is empty.
 ///
 /// The `placeholder` can be any widget, the `Text!` widget is recommended.
 #[property(CHILD, widget_impl(TextInput))]
diff --git a/crates/zng-wgt-text/l10n/template/_.ftl b/crates/zng-wgt-text/l10n/template/_.ftl
new file mode 100644
index 000000000..c06268d48
--- /dev/null
+++ b/crates/zng-wgt-text/l10n/template/_.ftl
@@ -0,0 +1,2 @@
+SELECT_ALL_CMD =
+    .name = Select All
diff --git a/crates/zng-wgt-undo-history/l10n/template/_.ftl b/crates/zng-wgt-undo-history/l10n/template/_.ftl
new file mode 100644
index 000000000..46975d0b9
--- /dev/null
+++ b/crates/zng-wgt-undo-history/l10n/template/_.ftl
@@ -0,0 +1,4 @@
+# count_actions:
+#     Number of undo/redo actions that are selected to run
+UndoHistory =
+    .count_actions = {$n} actions
diff --git a/crates/zng-wgt-undo-history/src/lib.rs b/crates/zng-wgt-undo-history/src/lib.rs
index 444e51921..03b5476af 100644
--- a/crates/zng-wgt-undo-history/src/lib.rs
+++ b/crates/zng-wgt-undo-history/src/lib.rs
@@ -216,6 +216,7 @@ pub fn default_undo_stack_fn(args: UndoStackArgs) -> impl UiNode {
         };
         L10nArgument::from(c)
     });
+    // l10n-# Number of undo/redo actions that are selected to run
     let count = l10n!("UndoHistory.count_actions", "{$n} actions", n = count);
 
     Container! {
diff --git a/crates/zng/src/l10n.rs b/crates/zng/src/l10n.rs
index 030abb3ee..c4070a014 100644
--- a/crates/zng/src/l10n.rs
+++ b/crates/zng/src/l10n.rs
@@ -148,6 +148,6 @@
 //! See [`zng_ext_l10n`] for the full localization API.
 
 pub use zng_ext_l10n::{
-    l10n, lang, IntoL10nVar, L10nArgument, L10nDir, L10nMessageBuilder, L10nSource, Lang, LangMap, LangResource, LangResourceStatus,
-    LangResources, Langs, NilL10nSource, SwapL10nSource, L10N, LANG_VAR,
+    l10n, lang, IntoL10nVar, L10nArgument, L10nDir, L10nMessageBuilder, L10nSource, Lang, LangFilePath, LangMap, LangResource,
+    LangResourceStatus, LangResources, Langs, NilL10nSource, SwapL10nSource, L10N, LANG_VAR,
 };
diff --git a/examples/localize/res/ar.ftl b/examples/localize/res/ar/_.ftl
similarity index 100%
rename from examples/localize/res/ar.ftl
rename to examples/localize/res/ar/_.ftl
diff --git a/examples/localize/res/en-US.ftl b/examples/localize/res/en-US/_.ftl
similarity index 100%
rename from examples/localize/res/en-US.ftl
rename to examples/localize/res/en-US/_.ftl
diff --git a/examples/localize/res/pseudo-mirr.ftl b/examples/localize/res/pseudo-mirr.ftl
deleted file mode 100644
index 088d25001..000000000
--- a/examples/localize/res/pseudo-mirr.ftl
+++ /dev/null
@@ -1,29 +0,0 @@
-### Localize Example
-### This standalone comment is added to all scraped template files.
-### This standalone comment is only added to the default file.
-### Another standalone comment, also added to the top of the template file.
-
-# icon:
-#     first syllable of "Localize"
-# title:
-#     Main window title
-window =
-    .icon = ⅂o
-    .title = ⅂oɔɐʅızǝ Ǝxɐɯdʅǝ (ʇǝɯdʅɐʇǝ)
-
-## Commands
-
-LOCALIZED_CMD =
-    .info = ⅂oɔɐʅızǝp ıu ʇɥǝ pǝɟɐnʅʇ ɟıʅǝ.
-    .name = ⅂oɔɐʅızǝp
-
-PRIVATE_LOCALIZED_CMD =
-    .info = Ԁɹıʌɐʇǝ ɔoɯɯɐup, dnqʅıɔ ʅoɔɐʅızɐʇıou ʇǝxʇ.
-    .name = Ԁɹıʌɐʇǝ
-
-## Example Section
-
-# button sets "click-count"
-button = Ԑnʇʇou
-
-example-cmds = Ǝxɐɯdʅǝ Ↄoɯɯɐups:
diff --git a/examples/localize/res/pseudo-mirr/msg.ftl b/examples/localize/res/pseudo-mirr/msg.ftl
deleted file mode 100644
index 0440aa5c2..000000000
--- a/examples/localize/res/pseudo-mirr/msg.ftl
+++ /dev/null
@@ -1,13 +0,0 @@
-### Localize Example
-### This standalone comment is added to all scraped template files.
-### This standalone comment is only added to the `msg` file.
-
-## Commands
-
-LOCALIZED_FILE_CMD =
-    .info = ⅂oɔɐʅızǝp ıu ɐ uɐɯǝp ɟıʅǝ 'ɯsƃ'.
-    .name = ⅂oɔɐʅızǝp Ⅎıʅǝ
-
-## Example Section
-
-click-count = Ↄʅıɔʞǝp {$n} ʇıɯǝs
diff --git a/examples/localize/res/pseudo-wide.ftl b/examples/localize/res/pseudo-wide.ftl
deleted file mode 100644
index e3a340599..000000000
--- a/examples/localize/res/pseudo-wide.ftl
+++ /dev/null
@@ -1,29 +0,0 @@
-### Localize Example
-### This standalone comment is added to all scraped template files.
-### This standalone comment is only added to the default file.
-### Another standalone comment, also added to the top of the template file.
-
-# icon:
-#     first syllable of "Localize"
-# title:
-#     Main window title
-window =
-    .icon = Ŀoo
-    .title = Ŀooƈaaŀiẑee Eẋaaḿƥŀee (ŧeeḿƥŀaaŧee)
-
-## Commands
-
-LOCALIZED_CMD =
-    .info = Ŀooƈaaŀiẑeeḓ iƞ ŧħee ḓeeƒaauuŀŧ ƒiŀee.
-    .name = Ŀooƈaaŀiẑeeḓ
-
-PRIVATE_LOCALIZED_CMD =
-    .info = Ƥřiṽaaŧee ƈooḿḿaaƞḓ, ƥuuƀŀiƈ ŀooƈaaŀiẑaaŧiooƞ ŧeeẋŧ.
-    .name = Ƥřiṽaaŧee
-
-## Example Section
-
-# button sets "click-count"
-button = Ɓuuŧŧooƞ
-
-example-cmds = Eẋaaḿƥŀee Ƈooḿḿaaƞḓş:
diff --git a/examples/localize/res/pseudo-wide/msg.ftl b/examples/localize/res/pseudo-wide/msg.ftl
deleted file mode 100644
index ff8af4e86..000000000
--- a/examples/localize/res/pseudo-wide/msg.ftl
+++ /dev/null
@@ -1,13 +0,0 @@
-### Localize Example
-### This standalone comment is added to all scraped template files.
-### This standalone comment is only added to the `msg` file.
-
-## Commands
-
-LOCALIZED_FILE_CMD =
-    .info = Ŀooƈaaŀiẑeeḓ iƞ aa ƞaaḿeeḓ ƒiŀee 'ḿşɠ'.
-    .name = Ŀooƈaaŀiẑeeḓ Ƒiŀee
-
-## Example Section
-
-click-count = Ƈŀiƈķeeḓ {$n} ŧiḿeeş
diff --git a/examples/localize/res/pseudo.ftl b/examples/localize/res/pseudo.ftl
deleted file mode 100644
index 749509601..000000000
--- a/examples/localize/res/pseudo.ftl
+++ /dev/null
@@ -1,29 +0,0 @@
-### Localize Example
-### This standalone comment is added to all scraped template files.
-### This standalone comment is only added to the default file.
-### Another standalone comment, also added to the top of the template file.
-
-# icon:
-#     first syllable of "Localize"
-# title:
-#     Main window title
-window =
-    .icon = Ŀo
-    .title = Ŀoƈaŀiẑe Eẋaḿƥŀe (ŧeḿƥŀaŧe)
-
-## Commands
-
-LOCALIZED_CMD =
-    .info = Ŀoƈaŀiẑeḓ iƞ ŧħe ḓeƒauŀŧ ƒiŀe.
-    .name = Ŀoƈaŀiẑeḓ
-
-PRIVATE_LOCALIZED_CMD =
-    .info = Ƥřiṽaŧe ƈoḿḿaƞḓ, ƥuƀŀiƈ ŀoƈaŀiẑaŧioƞ ŧeẋŧ.
-    .name = Ƥřiṽaŧe
-
-## Example Section
-
-# button sets "click-count"
-button = Ɓuŧŧoƞ
-
-example-cmds = Eẋaḿƥŀe Ƈoḿḿaƞḓş:
diff --git a/examples/localize/res/pseudo/msg.ftl b/examples/localize/res/pseudo/msg.ftl
deleted file mode 100644
index e57b0b2e8..000000000
--- a/examples/localize/res/pseudo/msg.ftl
+++ /dev/null
@@ -1,13 +0,0 @@
-### Localize Example
-### This standalone comment is added to all scraped template files.
-### This standalone comment is only added to the `msg` file.
-
-## Commands
-
-LOCALIZED_FILE_CMD =
-    .info = Ŀoƈaŀiẑeḓ iƞ a ƞaḿeḓ ƒiŀe 'ḿşɠ'.
-    .name = Ŀoƈaŀiẑeḓ Ƒiŀe
-
-## Example Section
-
-click-count = Ƈŀiƈķeḓ {$n} ŧiḿeş
diff --git a/examples/localize/res/pt-BR.ftl b/examples/localize/res/pt-BR/_.ftl
similarity index 100%
rename from examples/localize/res/pt-BR.ftl
rename to examples/localize/res/pt-BR/_.ftl
diff --git a/examples/localize/res/template.ftl b/examples/localize/res/template/_.ftl
similarity index 100%
rename from examples/localize/res/template.ftl
rename to examples/localize/res/template/_.ftl
diff --git a/examples/localize/res/template/deps/.gitignore b/examples/localize/res/template/deps/.gitignore
new file mode 100644
index 000000000..6ebb3b01d
--- /dev/null
+++ b/examples/localize/res/template/deps/.gitignore
@@ -0,0 +1,5 @@
+# Dependency localization files
+# Call `cargo zng l10n --package zng-example-localize` to update
+
+*
+!.gitignore
diff --git a/examples/localize/src/main.rs b/examples/localize/src/main.rs
index 528e39993..d275fa8ef 100644
--- a/examples/localize/src/main.rs
+++ b/examples/localize/src/main.rs
@@ -6,10 +6,11 @@ use std::path::PathBuf;
 use std::sync::Arc;
 
 use zng::{
+    clipboard::{COPY_CMD, PASTE_CMD},
     color::filter::drop_shadow,
     focus::{alt_focus_scope, focus_click_behavior, FocusClickBehavior},
     image,
-    l10n::LangMap,
+    l10n::{LangFilePath, LangMap},
     layout::align,
     prelude::*,
     widget::node::presenter,
@@ -21,7 +22,7 @@ use zng::{
 // l10n-msg-### This standalone comment is only added to the `msg` file.
 
 // Run this command to scrap template:
-// cargo do zng l10n "examples/localize/src" "examples/localize/res"
+// cargo do zng l10n -p "zng-example-localize" -o "examples/localize/res"
 
 fn main() {
     zng::env::init_res(concat!(env!("CARGO_MANIFEST_DIR"), "/res"));
@@ -79,6 +80,8 @@ fn window_content() -> impl UiNode {
         PRIVATE_LOCALIZED_CMD,
         L10N_FALSE_CMD,
         LOCALIZED_FILE_CMD,
+        COPY_CMD,
+        PASTE_CMD,
     ];
     let handles: Vec<_> = test_cmds.iter().map(|c| c.subscribe(true)).collect();
     handles.leak(); // perm enable commands for test
@@ -130,7 +133,7 @@ fn locale_menu() -> impl UiNode {
         focus_click_behavior = FocusClickBehavior::Exit;
         child = presenter(
             L10N.available_langs(),
-            wgt_fn!(|langs: Arc<LangMap<HashMap<Txt, PathBuf>>>| {
+            wgt_fn!(|langs: Arc<LangMap<HashMap<LangFilePath, PathBuf>>>| {
                 let mut actual = vec![];
                 let mut pseudo = vec![];
                 let mut template = vec![];
diff --git a/tools/cargo-do/src/main.rs b/tools/cargo-do/src/main.rs
index 040bc6448..0a05eda3d 100644
--- a/tools/cargo-do/src/main.rs
+++ b/tools/cargo-do/src/main.rs
@@ -15,6 +15,7 @@ fn main() {
         "run" | "r" => run(args),
         "zng" => cargo_zng(args),
         "doc" => doc(args),
+        "l10n" => l10n(args),
         "expand" => expand(args),
         "check" | "c" => check(args),
         "build" | "b" => build(args),
@@ -331,6 +332,41 @@ fn doc(mut args: Vec<&str>) {
     }
 }
 
+// do l10n [-p, --package <pkg>]
+//         [--all]
+//
+//    Scrap localization files for publishing. Localization filers are placed in
+//    crate-dir/l0n/ for each crate. The l10n/ dir is cleared before scrapping.
+//
+// USAGE:
+//     l10n --all
+//          Scrap for all publishable crates in workspace.
+//     l10n -p <pkg>
+//          Scrap for the specific workspace member.
+fn l10n(mut args: Vec<&str>) {
+    let crates = if let Some(p) = take_option(&mut args, &["-p", "--package"], "<pkg>") {
+        p.into_iter().map(|s| format!("crates/{s}/Cargo.toml")).collect()
+    } else {
+        if !take_flag(&mut args, &["--all"]) {
+            fatal("expected --package or --all")
+        }
+        util::top_cargo_toml("crates")
+    };
+
+    cmd_req("cargo", &["build", "--package", "cargo-zng"], &[]);
+    let exe = format!("target/debug/cargo-zng{}", std::env::consts::EXE_SUFFIX);
+    for manifest_path in crates {
+        let output = std::path::Path::new(&manifest_path).with_file_name("l10n");
+        if let Err(e) = std::fs::remove_dir_all(&output) {
+            if !matches!(e.kind(), std::io::ErrorKind::NotFound) {
+                error(f!("cannot clear `{}`, {e}", output.display()));
+                continue;
+            }
+        }
+        cmd(&exe, &["zng", "l10n", "--no-deps", "--manifest-path", manifest_path.as_str()], &[]);
+    }
+}
+
 // do test, t [-u, --unit <function-path>]
 //            [-t, --test <integration-test-name>]
 //            [-m, --macro <file-path-pattern>]


Test patch: