1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-07 17:03:01 +03:00

i18n: include context when looking for translation keys

This commit is contained in:
Quentin Gliech
2023-10-03 16:45:24 +02:00
parent 2b645f7be4
commit 2d52ba7fb3
8 changed files with 316 additions and 59 deletions

View File

@@ -13,10 +13,12 @@
// limitations under the License. // limitations under the License.
use mas_i18n::{translations::TranslationTree, Message}; use mas_i18n::{translations::TranslationTree, Message};
use minijinja::machinery::Span;
pub struct Context { pub struct Context {
keys: Vec<Key>, keys: Vec<Key>,
func: String, func: String,
current_file: Option<String>,
} }
impl Context { impl Context {
@@ -24,9 +26,14 @@ impl Context {
Self { Self {
keys: Vec::new(), keys: Vec::new(),
func, func,
current_file: None,
} }
} }
pub fn set_current_file(&mut self, file: &str) {
self.current_file = Some(file.to_owned());
}
pub fn record(&mut self, key: Key) { pub fn record(&mut self, key: Key) {
self.keys.push(key); self.keys.push(key);
} }
@@ -39,6 +46,28 @@ impl Context {
let mut count = 0; let mut count = 0;
for translatable in &self.keys { for translatable in &self.keys {
let message = Message::from_literal(translatable.default_value()); let message = Message::from_literal(translatable.default_value());
let location = translatable.location.as_ref().map(|location| {
if location.span.start_line == location.span.end_line {
format!(
"{}:{}:{}-{}",
location.file,
location.span.start_line,
location.span.start_col,
location.span.end_col
)
} else {
format!(
"{}:{}:{}-{}:{}",
location.file,
location.span.start_line,
location.span.start_col,
location.span.end_line,
location.span.end_col
)
}
});
let key = translatable let key = translatable
.key .key
.split('.') .split('.')
@@ -48,12 +77,23 @@ impl Context {
None None
}); });
if translation_tree.set_if_not_defined(key, message) { if translation_tree.set_if_not_defined(key, message, location) {
count += 1; count += 1;
} }
} }
count count
} }
pub fn set_key_location(&self, mut key: Key, span: Span) -> Key {
if let Some(file) = &self.current_file {
key.location = Some(Location {
file: file.to_owned(),
span,
});
}
key
}
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -62,15 +102,26 @@ pub enum Kind {
Plural, Plural,
} }
#[derive(Debug, Clone)]
pub struct Location {
file: String,
span: Span,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Key { pub struct Key {
kind: Kind, kind: Kind,
key: String, key: String,
location: Option<Location>,
} }
impl Key { impl Key {
pub fn new(kind: Kind, key: String) -> Self { pub fn new(kind: Kind, key: String) -> Self {
Self { kind, key } Self {
kind,
key,
location: None,
}
} }
pub fn default_value(&self) -> String { pub fn default_value(&self) -> String {

View File

@@ -82,6 +82,7 @@ fn main() {
let template = std::fs::read_to_string(&path).expect("Failed to read template"); let template = std::fs::read_to_string(&path).expect("Failed to read template");
match minijinja::parse(&template, relative.as_str()) { match minijinja::parse(&template, relative.as_str()) {
Ok(ast) => { Ok(ast) => {
context.set_current_file(relative.as_str());
minijinja::find_in_stmt(&mut context, &ast).unwrap(); minijinja::find_in_stmt(&mut context, &ast).unwrap();
} }
Err(err) => { Err(err) => {
@@ -103,6 +104,7 @@ fn main() {
let mut file = File::options() let mut file = File::options()
.write(true) .write(true)
.read(false) .read(false)
.truncate(true)
.open( .open(
options options
.existing .existing

View File

@@ -14,7 +14,7 @@
pub use minijinja::machinery::parse; pub use minijinja::machinery::parse;
use minijinja::{ use minijinja::{
machinery::ast::{Call, Const, Expr, Macro, Stmt}, machinery::ast::{Call, Const, Expr, Macro, Spanned, Stmt},
ErrorKind, ErrorKind,
}; };
@@ -113,7 +113,11 @@ fn find_in_macro<'a>(context: &mut Context, macro_: &'a Macro<'a>) -> Result<(),
Ok(()) Ok(())
} }
fn find_in_call<'a>(context: &mut Context, call: &'a Call<'a>) -> Result<(), minijinja::Error> { fn find_in_call<'a>(
context: &mut Context,
call: &'a Spanned<Call<'a>>,
) -> Result<(), minijinja::Error> {
let span = call.span();
if let Expr::Var(var_) = &call.expr { if let Expr::Var(var_) = &call.expr {
if var_.id == context.func() { if var_.id == context.func() {
let key = call let key = call
@@ -134,14 +138,18 @@ fn find_in_call<'a>(context: &mut Context, call: &'a Call<'a>) -> Result<(), min
} }
}); });
context.record(Key::new( let key = Key::new(
if has_count { if has_count {
crate::key::Kind::Plural crate::key::Kind::Plural
} else { } else {
crate::key::Kind::Message crate::key::Kind::Message
}, },
key.to_owned(), key.to_owned(),
)); );
let key = context.set_key_location(key, span);
context.record(key);
} }
} }

View File

@@ -80,4 +80,4 @@ text = @{ (!start ~ ANY)+ }
start = _{ "%" } start = _{ "%" }
number = @{ ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* } number = @{ ASCII_NONZERO_DIGIT ~ ASCII_DIGIT* }
ident = @{ ASCII_ALPHA ~ ASCII_ALPHANUMERIC* } ident = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* }

View File

@@ -12,10 +12,17 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use std::{collections::BTreeMap, ops::Deref}; use std::{
collections::{BTreeMap, BTreeSet},
ops::Deref,
};
use icu_plurals::PluralCategory; use icu_plurals::PluralCategory;
use serde::{Deserialize, Serialize}; use serde::{
de::{MapAccess, Visitor},
ser::SerializeMap,
Deserialize, Deserializer, Serialize, Serializer,
};
use crate::sprintf::Message; use crate::sprintf::Message;
@@ -30,20 +37,137 @@ fn plural_category_as_str(category: PluralCategory) -> &'static str {
} }
} }
#[derive(Debug, Clone, Serialize, Deserialize)] pub type TranslationTree = Tree;
#[serde(untagged)]
pub enum TranslationTree { #[derive(Debug, Clone, Deserialize, Default)]
Message(Message), pub struct Metadata {
Children(BTreeMap<String, TranslationTree>), #[serde(skip)]
// We don't want to deserialize it, as we're resetting it every time
// This then generates the `context` field when serializing
pub context_locations: BTreeSet<String>,
pub description: Option<String>,
} }
impl Default for TranslationTree { impl Serialize for Metadata {
fn default() -> Self { fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
Self::Children(BTreeMap::new()) where
S: Serializer,
{
let context = self
.context_locations
.iter()
.map(String::as_str)
.collect::<Vec<&str>>()
.join(", ");
let mut map = serializer.serialize_map(None)?;
if !context.is_empty() {
map.serialize_entry("context", &context)?;
}
if let Some(description) = &self.description {
map.serialize_entry("description", description)?;
}
map.end()
} }
} }
impl TranslationTree { impl Metadata {
fn add_location(&mut self, location: String) {
self.context_locations.insert(location);
}
}
#[derive(Debug, Clone, Default)]
pub struct Tree {
inner: BTreeMap<String, Node>,
}
#[derive(Debug, Clone)]
pub struct Node {
metadata: Option<Metadata>,
value: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Value {
Tree(Tree),
Leaf(Message),
}
impl<'de> Deserialize<'de> for Tree {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct TreeVisitor;
impl<'de> Visitor<'de> for TreeVisitor {
type Value = Tree;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("map")
}
fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where
A: MapAccess<'de>,
{
let mut tree: BTreeMap<String, Node> = BTreeMap::new();
let mut metadata_map: BTreeMap<String, Metadata> = BTreeMap::new();
while let Some(key) = map.next_key::<String>()? {
if let Some(name) = key.strip_prefix('@') {
let metadata = map.next_value::<Metadata>()?;
metadata_map.insert(name.to_owned(), metadata);
} else {
let value = map.next_value::<Value>()?;
tree.insert(
key,
Node {
metadata: None,
value,
},
);
}
}
for (key, meta) in metadata_map {
if let Some(node) = tree.get_mut(&key) {
node.metadata = Some(meta);
}
}
Ok(Tree { inner: tree })
}
}
deserializer.deserialize_any(TreeVisitor)
}
}
impl Serialize for Tree {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let mut map = serializer.serialize_map(None)?;
for (key, value) in &self.inner {
map.serialize_entry(key, &value.value)?;
if let Some(meta) = &value.metadata {
map.serialize_entry(&format!("@{key}"), meta)?;
}
}
map.end()
}
}
impl Tree {
/// Get a message from the tree by key. /// Get a message from the tree by key.
/// ///
/// Returns `None` if the requested key is not found. /// Returns `None` if the requested key is not found.
@@ -51,7 +175,7 @@ impl TranslationTree {
pub fn message(&self, key: &str) -> Option<&Message> { pub fn message(&self, key: &str) -> Option<&Message> {
let keys = key.split('.'); let keys = key.split('.');
let node = self.walk_path(keys)?; let node = self.walk_path(keys)?;
let message = node.as_message()?; let message = node.value.as_message()?;
Some(message) Some(message)
} }
@@ -65,19 +189,20 @@ impl TranslationTree {
let keys = key.split('.'); let keys = key.split('.');
let node = self.walk_path(keys)?; let node = self.walk_path(keys)?;
let subtree = match node { let subtree = match &node.value {
TranslationTree::Message(message) => return Some(message), Value::Leaf(message) => return Some(message),
TranslationTree::Children(tree) => tree, Value::Tree(tree) => tree,
}; };
if let Some(node) = subtree.get(plural_category_as_str(category)) { let node = if let Some(node) = subtree.inner.get(plural_category_as_str(category)) {
let message = node.as_message()?; node
Some(message)
} else { } else {
// Fallback to the "other" category // Fallback to the "other" category
let message = subtree.get("other")?.as_message()?; subtree.inner.get("other")?
Some(message) };
}
let message = node.value.as_message()?;
Some(message)
} }
#[doc(hidden)] #[doc(hidden)]
@@ -85,48 +210,100 @@ impl TranslationTree {
&mut self, &mut self,
path: I, path: I,
value: Message, value: Message,
location: Option<String>,
) -> bool { ) -> bool {
let mut path = path.into_iter(); // We're temporarily moving the tree out of the struct to be able to nicely
let Some(next) = path.next() else { // iterate on it
if let TranslationTree::Message(_) = self { let mut fake_root = Node {
return false; metadata: None,
} value: Value::Tree(Tree {
inner: std::mem::take(&mut self.inner),
*self = TranslationTree::Message(value); }),
return true;
}; };
match self { let mut node = &mut fake_root;
TranslationTree::Message(_) => panic!("cannot set a value on a message node"), for key in path {
TranslationTree::Children(children) => children match &mut node.value {
.entry(next.deref().to_owned()) Value::Tree(tree) => {
.or_default() node = tree.inner.entry(key.deref().to_owned()).or_insert(Node {
.set_if_not_defined(path, value), metadata: None,
value: Value::Tree(Tree::default()),
});
}
Value::Leaf(_) => {
panic!()
}
}
} }
let replaced = match &node.value {
Value::Tree(tree) => {
assert!(
tree.inner.is_empty(),
"Trying to overwrite a non-empty tree"
);
node.value = Value::Leaf(value);
true
}
Value::Leaf(_) => {
// Do not overwrite existing values
false
}
};
if let Some(location) = location {
node.metadata
.get_or_insert(Metadata::default())
.add_location(location);
}
// Restore the original tree at the end of the function
match fake_root {
Node {
value: Value::Tree(tree),
..
} => self.inner = tree.inner,
_ => panic!("Tried to replace the root node"),
};
replaced
} }
fn walk_path<K: Deref<Target = str>, I: IntoIterator<Item = K>>( fn walk_path<K: Deref<Target = str>, I: IntoIterator<Item = K>>(
&self, &self,
path: I, path: I,
) -> Option<&TranslationTree> { ) -> Option<&Node> {
let mut path = path.into_iter(); let mut iterator = path.into_iter();
let Some(next) = path.next() else { let Some(next) = iterator.next() else {
return Some(self); return None;
}; };
match self { self.walk_path_inner(next, iterator)
TranslationTree::Message(_) => None,
TranslationTree::Children(tree) => {
let child = tree.get(&*next)?;
child.walk_path(path)
}
}
} }
fn walk_path_inner<K: Deref<Target = str>, I: Iterator<Item = K>>(
&self,
next_key: K,
mut path: I,
) -> Option<&Node> {
let next = self.inner.get(next_key.deref())?;
match path.next() {
Some(next_key) => match &next.value {
Value::Tree(tree) => tree.walk_path_inner(next_key, path),
Value::Leaf(_) => None,
},
None => Some(next),
}
}
}
impl Value {
fn as_message(&self) -> Option<&Message> { fn as_message(&self) -> Option<&Message> {
match self { match self {
TranslationTree::Message(message) => Some(message), Value::Leaf(message) => Some(message),
TranslationTree::Children(_) => None, Value::Tree(_) => None,
} }
} }
} }

View File

@@ -19,7 +19,7 @@ limitations under the License.
{% block content %} {% block content %}
<section class="flex-1 flex items-center justify-center"> <section class="flex-1 flex items-center justify-center">
<div class="w-64 flex flex-col gap-2"> <div class="w-64 flex flex-col gap-2">
<h1 class="text-xl font-semibold">Unexpected error</h1> <h1 class="text-xl font-semibold">{{ _("error.unexpected") }}</h1>
{% if code %} {% if code %}
<p class="font-semibold font-mono"> <p class="font-semibold font-mono">
{{ code }} {{ code }}

View File

@@ -22,8 +22,7 @@ limitations under the License.
<div class="my-2 mx-8"> <div class="my-2 mx-8">
<h1 class="my-2 text-5xl font-semibold leading-tight">{{ _("app.human_name") }}</h1> <h1 class="my-2 text-5xl font-semibold leading-tight">{{ _("app.human_name") }}</h1>
<p class="text-lg"> <p class="text-lg">
OpenID Connect discovery document: {{ _("app.technical_description", discovery_url=discovery_url) }}
<a class="cpd-link" data-kind="primary" href="{{ discovery_url }}">{{ discovery_url }}</a>
</p> </p>
</div> </div>
</section> </section>

View File

@@ -1,6 +1,26 @@
{ {
"app": { "app": {
"human_name": "Matrix Authentication Service", "human_name": "Matrix Authentication Service",
"name": "matrix-authentication-service" "@human_name": {
"context": "pages/index.html:23:63-82",
"description": "Human readable name of the application"
},
"name": "matrix-authentication-service",
"@name": {
"context": "app.html:26:14-27, base.html:32:31-44",
"description": "Name of the application"
},
"technical_description": "OpenID Connect discovery document: <a class=\"cpd-link\" data-kind=\"primary\" href=\"%(discovery_url)s\">%(discovery_url)s</a>",
"@technical_description": {
"context": "pages/index.html:25:11-70",
"description": "Introduction text displayed on the home page"
}
},
"error": {
"unexpected": "Unexpected error",
"@unexpected": {
"context": "pages/error.html:22:41-62",
"description": "Error message displayed when an unexpected error occurs"
}
} }
} }