improve localization

This commit is contained in:
Hannaeko 2025-04-02 00:49:56 +02:00
parent 1fd5ce890b
commit 95d38c5514
12 changed files with 108 additions and 52 deletions

View file

@ -1,5 +0,0 @@
zone_records_title = Zone { $zone_name } records
record_type_address = Addresses
record_type_mailserver = E-mail servers
record_type_nameserver = Name servers

21
locales/en/main.ftl Normal file
View file

@ -0,0 +1,21 @@
zone-header = Zone { $zone_name }
zone-content-title = Zone content
zone-content-records-header = Records
zone-content-aliases-header = Aliases
zone-content-section-web-header = Web
zone-content-section-mail-header = E-mail
zone-content-section-general-header = General
zone-content-record-type-address =
.type-name = Addresses
zone-content-record-type-mailserver =
.type-name = E-mail servers
.data-preference = Preference: { $preference }
zone-content-record-type-nameserver =
.type-name = Name servers
zone-content-new-record-button = New record

View file

@ -1,5 +0,0 @@
zone_records_title = Enregistrements de la zone { $zone_name }
record_type_address = Adresses
record_type_mailserver = Serveurs de courriel
record_type_nameserver = Serveurs de noms

22
locales/fr/main.ftl Normal file
View file

@ -0,0 +1,22 @@
zone-header = Zone { $zone_name }
zone-content-title = Contenu de la zone
zone-content-records-header = Enregistrements
zone-content-aliases-header = Alias
zone-content-section-web-header = Web
zone-content-section-mail-header = Courriel
zone-content-section-general-header = Général
zone-content-record-type-address =
.type-name = Adresses
zone-content-record-type-mailserver =
.type-name = Serveurs de courriel
.data-preference = Préférence : { $preference }
zone-content-record-type-nameserver =
.type-name = Serveurs de noms
zone-content-new-record-button = Nouvel enregistrement

View file

@ -1,35 +1,53 @@
use std::{collections::HashMap, str::FromStr}; use std::{collections::HashMap, fs, path::Path, str::FromStr};
use fluent_bundle::{concurrent::FluentBundle, FluentArgs, FluentResource, FluentValue}; use fluent_bundle::{concurrent::FluentBundle, FluentArgs, FluentResource, FluentValue};
use unic_langid::LanguageIdentifier; use unic_langid::LanguageIdentifier;
const SOURCES: &[(&str, &str)] = &[
("en", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/en.ftl"))),
("fr", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/locales/fr.ftl"))),
];
#[derive(Clone)] #[derive(Clone)]
pub struct Localization { pub struct Localization {
bundles: std::sync::Arc<HashMap<LanguageIdentifier, FluentBundle<FluentResource>>>, bundles: std::sync::Arc<HashMap<LanguageIdentifier, FluentBundle<FluentResource>>>,
} }
impl Localization { impl Localization {
pub fn init() -> Self { pub fn init(locale_directory: &Path) -> Self {
let mut bundles = HashMap::new(); let mut bundles = HashMap::new();
for (locale, translations) in SOURCES { let directory_content =fs::read_dir(locale_directory)
let res = FluentResource::try_new(translations.to_string()).expect("Failed to parse an FTL string."); .expect("Unable to read locales directory");
let langid: LanguageIdentifier = locale.parse().expect("Parsing failed");
let mut bundle = FluentBundle::new_concurrent(vec![langid.clone()]); for lang_dir in directory_content {
let lang_dir = lang_dir.expect("I/O error");
if lang_dir.path().is_dir() {
let langid: LanguageIdentifier = lang_dir.file_name()
.into_string()
.expect("String convertion failed")
.parse()
.expect("Failed to parse language tag");
bundle let mut bundle = FluentBundle::new_concurrent(vec![langid.clone()]);
.add_resource(res)
.expect("Failed to add FTL resources to the bundle.");
bundles.insert(langid, bundle); let directory_content = fs::read_dir(lang_dir.path())
.expect("Unable to read locales directory");
for resource in directory_content {
let resource = resource.expect("I/O error");
if resource.path().is_file() {
let resource = fs::read_to_string(resource.path())
.expect("Failed to open file");
let resource = FluentResource::try_new(resource)
.expect("Failed to parse an FTL string.");
bundle
.add_resource(resource)
.expect("Failed to add FTL resources to the bundle.");
}
}
bundles.insert(langid, bundle);
}
} }
Localization { Localization {
bundles: std::sync::Arc::new(bundles), bundles: std::sync::Arc::new(bundles),
} }
@ -54,20 +72,29 @@ impl tera::Function for Localization {
.and_then(|lang| lang.as_str()) .and_then(|lang| lang.as_str())
.ok_or(tera::Error::msg("localize: Missing msg parameter"))?; .ok_or(tera::Error::msg("localize: Missing msg parameter"))?;
let attribute = args.get("attr")
.and_then(|lang| lang.as_str());
let bundle = self.bundles.get(&locale) let bundle = self.bundles.get(&locale)
.ok_or_else(|| tera::Error::msg("localize: Could not find locale {locale}"))?; .ok_or_else(|| tera::Error::msg(format!("localize: Could not find locale {locale}")))?;
let message = bundle.get_message(msg) let message = bundle.get_message(msg)
.ok_or_else(|| tera::Error::msg("localize: Could not find message {msg}"))?; .ok_or_else(|| tera::Error::msg(format!("localize: Could not find message {msg}")))?;
let pattern = message.value() let pattern = if let Some(attribute) = attribute {
.ok_or_else(|| tera::Error::msg("localize: Message {msg} has no value"))?; message.get_attribute(attribute)
.map(|attribute| attribute.value())
.ok_or_else(|| tera::Error::msg(format!("localize: Attribute {msg}.{attribute} has no value")))?
} else {
message.value()
.ok_or_else(|| tera::Error::msg(format!("localize: Message {msg} has no value")))?
};
let mut msg_args = FluentArgs::new(); let mut msg_args = FluentArgs::new();
for (key, value) in args { for (key, value) in args {
if key != "msg" && key != "lang" { if key != "msg" && key != "lang" && key != "attr" {
msg_args.set(key, fluent_value_from_tera(value, key)?); msg_args.set(key, fluent_value_from_tera(value, key)?);
} }
} }

View file

@ -7,7 +7,7 @@ mod validation;
mod macros; mod macros;
mod template; mod template;
mod proto; mod proto;
mod locale; mod localization;
use std::sync::Arc; use std::sync::Arc;
@ -33,7 +33,7 @@ pub struct AppState {
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let localization = locale::Localization::init(); let localization = localization::Localization::init(std::path::Path::new("./locales"));
let template_engine = TemplateEngine::new( let template_engine = TemplateEngine::new(
std::path::Path::new("./templates"), std::path::Path::new("./templates"),

View file

@ -1,5 +1,4 @@
pub mod rdata; pub mod rdata;
pub mod record; pub mod record;
pub use rdata::*;
pub use record::*; pub use record::*;

View file

@ -1,4 +1,3 @@
use axum::extract::Request;
use axum::extract::{Query, Path, State, OriginalUri}; use axum::extract::{Query, Path, State, OriginalUri};
use axum::Extension; use axum::Extension;
use serde::Deserialize; use serde::Deserialize;
@ -21,8 +20,6 @@ pub async fn get_records_page(
let records = zone.get_records(app.records).await?; let records = zone.get_records(app.records).await?;
let records = friendly::FriendlyRecords::from(records.clone()); let records = friendly::FriendlyRecords::from(records.clone());
println!("{}", lang);
Ok(Template::new( Ok(Template::new(
"pages/records.html", "pages/records.html",
app.template_engine, app.template_engine,

View file

@ -6,7 +6,7 @@ use axum::response::{Html, IntoResponse};
use serde::Serialize; use serde::Serialize;
use tera::{Tera, Context}; use tera::{Tera, Context};
use crate::{errors::Error, locale::Localization}; use crate::{errors::Error, localization::Localization};
#[derive(Clone)] #[derive(Clone)]

View file

@ -19,8 +19,8 @@ pub fn normalize_domain(domain_name: &str) -> Result<String, Error> {
let domain = domain_name.strip_suffix('.').unwrap_or(domain_name).to_lowercase(); let domain = domain_name.strip_suffix('.').unwrap_or(domain_name).to_lowercase();
if domain.as_bytes().len() > 255 { if domain.len() > 255 {
Err(Error::from(DomainValidationError::DomainTooLong { length: domain.as_bytes().len() })) Err(Error::from(DomainValidationError::DomainTooLong { length: domain.len() }))
} else { } else {
let labels = domain.split('.').collect::<Vec<_>>(); let labels = domain.split('.').collect::<Vec<_>>();
@ -40,10 +40,10 @@ pub fn normalize_domain(domain_name: &str) -> Result<String, Error> {
); );
} }
if label.as_bytes().len() > 63 { if label.len() > 63 {
return Err(Error::from(DomainValidationError::LabelToolLong { return Err(Error::from(DomainValidationError::LabelToolLong {
label: label.into(), label: label.into(),
length: label.as_bytes().len() length: label.len()
})); }));
} }
} }

View file

@ -1,7 +1,7 @@
{% macro rrset(rtype, ttl, data, zone, lang) %} {% macro rrset(rtype, ttl, data, zone, lang) %}
<li class="rrset"> <li class="rrset">
<div class="rtype"> <div class="rtype">
{{ tr(msg="record_type_" ~ rtype, lang=lang) }} {{ tr(msg="zone-content-record-type-" ~ rtype, attr="type-name", lang=lang) }}
<div class="action"> <div class="action">
<button class="icon"> <button class="icon">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16"> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
@ -35,7 +35,7 @@
</div> </div>
<div class="rdata-complementary"> <div class="rdata-complementary">
<span class="pill"> <span class="pill">
Preference: {{ data.preference }} {{ tr(msg="zone-content-record-type-mailserver", attr="data-preference", preference=data.preference, lang=lang) }}
</span> </span>
</div> </div>
{% elif rtype == "nameserver" %} {% elif rtype == "nameserver" %}

View file

@ -1,10 +1,10 @@
{% import "macros.html" as macros %} {% import "macros.html" as macros %}
{% extends "bases/app.html" %} {% extends "bases/app.html" %}
{% block title %}Records - {{ current_zone }} - {% endblock title %} {% block title %}{{ tr(msg="zone-content-title", lang=lang) }} - {{ current_zone }} - {% endblock title %}
{% block main %} {% block main %}
<h1>{{ tr(msg="zone_records_title", lang=lang, zone_name="<strong>" ~ current_zoned ~ "</strong>") | safe }}</h1> <h1>{{ tr(msg="zone-header", lang=lang, zone_name="<strong>" ~ current_zone ~ "</strong>") | safe }}</h1>
<svg width="0" height="0" aria-hidden="true" style="position: absolute;"> <svg width="0" height="0" aria-hidden="true" style="position: absolute;">
<defs> <defs>
<clipPath id="corner-folder-tab-right" clipPathUnits="objectBoundingBox"> <clipPath id="corner-folder-tab-right" clipPathUnits="objectBoundingBox">
@ -13,7 +13,7 @@
</defs> </defs>
</svg> </svg>
<section> <section>
<h2>Records</h2> <h2>{{ tr(msg="zone-content-records-header", lang=lang) }}</h2>
{% for group in records.records %} {% for group in records.records %}
<article class="domain"> <article class="domain">
<header> <header>
@ -24,12 +24,12 @@
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/> <path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/> <path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
</svg> </svg>
Add record {{ tr(msg="zone-content-new-record-button", lang=lang) }}
</a> </a>
</header> </header>
<div class="records"> <div class="records">
{% if group.web %} {% if group.web %}
<h4>Web</h4> <h4>{{ tr(msg="zone-content-section-web-header", lang=lang) }}</h4>
<ul> <ul>
{% if group.web.addresses %} {% if group.web.addresses %}
{{ macros::rrset( {{ macros::rrset(
@ -43,7 +43,7 @@
{% endif %} {% endif %}
{% if group.mail %} {% if group.mail %}
<h4>E-mails</h4> <h4>{{ tr(msg="zone-content-section-mail-header", lang=lang) }}</h4>
<ul> <ul>
{% if group.mail.servers %} {% if group.mail.servers %}
{{ macros::rrset( {{ macros::rrset(
@ -65,7 +65,7 @@
{% endif %} {% endif %}
{% if group.general_records %} {% if group.general_records %}
<h4>General</h4> <h4>{{ tr(msg="zone-content-section-general-header", lang=lang) }}</h4>
<ul> <ul>
{% for rrset in group.general_records %} {% for rrset in group.general_records %}
{{ macros::rrset( {{ macros::rrset(
@ -83,7 +83,7 @@
{% endfor %} {% endfor %}
</section> </section>
<section> <section>
<h2>Aliases</h2> <h2>{{ tr(msg="zone-content-aliases-header", lang=lang) }}</h2>
<ul> <ul>
{% for alias in records.aliases %} {% for alias in records.aliases %}