feat: localization #94

Merged
Anri merged 4 commits from localization into main 2024-12-22 19:46:12 +01:00
12 changed files with 120 additions and 50 deletions

4
.gitignore vendored
View file

@ -7,13 +7,13 @@ docker-compose.yml
/.vscode /.vscode
# Data # Data
data/index.md data/index*.md
data/contacts/* data/contacts/*
data/cours/* data/cours/*
data/projects/* data/projects/*
# Blog # Blog
data/blog/*.md data/blog/about*.md
data/blog/posts/* data/blog/posts/*
!data/blog/posts/Makefile !data/blog/posts/Makefile

View file

@ -145,6 +145,8 @@ Markdown file
Markdown file is stored in `/app/data/index.md` Markdown file is stored in `/app/data/index.md`
> For french clients, `/app/data/index-fr.md` will be read instead.
``` ```
--- ---
name: Option<String> name: Option<String>
@ -188,6 +190,8 @@ Post content
The file is stored at `/app/data/blog/about.md`. The file is stored at `/app/data/blog/about.md`.
> For french clients, `/app/data/blog/about-fr.md` will be read instead.
## Projects ## Projects
Markdown files are stored in `/app/data/projects/apps/` Markdown files are stored in `/app/data/projects/apps/`
@ -214,6 +218,8 @@ files in `archive` subdirectory of `apps`.
The file is stored at `/app/data/projects/about.md`. The file is stored at `/app/data/projects/about.md`.
> For french clients, `/app/data/projects/about-fr.md` will be read instead.
## Contacts ## Contacts
Markdown files are stored in `/app/data/contacts/` Markdown files are stored in `/app/data/contacts/`
@ -254,6 +260,8 @@ For example, `socials` contact files are stored in `/app/data/contacts/socials/`
The file is stored at `/app/data/contacts/about.md`. The file is stored at `/app/data/contacts/about.md`.
> For french clients, `/app/data/contacts/about-fr.md` will be read instead.
## Courses ## Courses
Markdown files are stored in `/app/data/cours/` Markdown files are stored in `/app/data/cours/`

View file

@ -7,7 +7,7 @@ use std::{fs::File, io::Write, path::Path};
use crate::template::Template; use crate::template::Template;
/// Store the configuration of config/config.toml /// Store the configuration of config/config.toml
#[derive(Clone, Debug, Default, Deserialize)] #[derive(Clone, Debug, Default, Deserialize, Hash, PartialEq, Eq)]
pub struct FileConfiguration { pub struct FileConfiguration {
/// http/https /// http/https
pub scheme: Option<String>, pub scheme: Option<String>,
@ -75,14 +75,14 @@ impl FileConfiguration {
} }
// Paths where files are stored // Paths where files are stored
#[derive(Clone, Debug)] #[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Locations { pub struct Locations {
pub static_dir: String, pub static_dir: String,
pub data_dir: String, pub data_dir: String,
} }
/// Configuration used internally in the app /// Configuration used internally in the app
#[derive(Clone, Debug)] #[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Config { pub struct Config {
/// Information given in the config file /// Information given in the config file
pub fc: FileConfiguration, pub fc: FileConfiguration,

View file

@ -1,5 +1,7 @@
use actix_web::{get, http::header::ContentType, routes, web, HttpResponse, Responder}; use actix_web::{
use cached::proc_macro::once; get, http::header::ContentType, routes, web, HttpRequest, HttpResponse, Responder,
};
use cached::proc_macro::cached;
use ramhorns::Content; use ramhorns::Content;
use crate::{ use crate::{
@ -8,14 +10,17 @@ use crate::{
utils::{ utils::{
markdown::{File, FilePath}, markdown::{File, FilePath},
metadata::MType, metadata::MType,
misc::{make_kw, read_file, Html}, misc::{lang, make_kw, read_file_fallback, Html, Lang},
routes::blog::{build_rss, get_post, get_posts, Post, BLOG_DIR, MIME_TYPE_RSS, POST_DIR}, routes::blog::{build_rss, get_post, get_posts, Post, BLOG_DIR, MIME_TYPE_RSS, POST_DIR},
}, },
}; };
#[get("/blog")] #[get("/blog")]
pub async fn index(config: web::Data<Config>) -> impl Responder { pub async fn index(req: HttpRequest, config: web::Data<Config>) -> impl Responder {
Html(build_index(config.get_ref().to_owned())) Html(build_index(
config.get_ref().to_owned(),
lang(req.headers()),
))
} }
#[derive(Content, Debug)] #[derive(Content, Debug)]
@ -26,18 +31,19 @@ struct BlogIndexTemplate {
no_posts: bool, no_posts: bool,
} }
#[once(time = 60)] #[cached(time = 60)]
fn build_index(config: Config) -> String { fn build_index(config: Config, lang: Lang) -> String {
let blog_dir = format!("{}/{}", config.locations.data_dir, BLOG_DIR); let blog_dir = format!("{}/{}", config.locations.data_dir, BLOG_DIR);
let mut posts = get_posts(&format!("{blog_dir}/{POST_DIR}")); let mut posts = get_posts(&format!("{blog_dir}/{POST_DIR}"));
// Get about // Get about
let about: Option<File> = read_file( let about = read_file_fallback(
FilePath { FilePath {
base: blog_dir, base: blog_dir,
path: "about.md".to_owned(), path: "about.md".to_owned(),
}, },
MType::Generic, MType::Generic,
lang,
); );
// Sort from newest to oldest // Sort from newest to oldest

View file

@ -1,5 +1,5 @@
use actix_web::{get, routes, web, HttpRequest, Responder}; use actix_web::{get, routes, web, HttpRequest, Responder};
use cached::proc_macro::once; use cached::proc_macro::cached;
use ramhorns::Content; use ramhorns::Content;
use crate::{ use crate::{
@ -8,7 +8,7 @@ use crate::{
utils::{ utils::{
markdown::{File, FilePath}, markdown::{File, FilePath},
metadata::MType, metadata::MType,
misc::{make_kw, read_file, Html}, misc::{lang, make_kw, read_file_fallback, Html, Lang},
routes::contact::{find_links, read, remove_paragraphs}, routes::contact::{find_links, read, remove_paragraphs},
}, },
}; };
@ -28,8 +28,8 @@ pub fn pages(cfg: &mut web::ServiceConfig) {
} }
#[get("")] #[get("")]
async fn page(config: web::Data<Config>) -> impl Responder { async fn page(req: HttpRequest, config: web::Data<Config>) -> impl Responder {
Html(build_page(config.get_ref().to_owned())) Html(build_page(config.get_ref().to_owned(), lang(req.headers())))
} }
#[routes] #[routes]
@ -78,18 +78,19 @@ struct NetworksTemplate {
others: Vec<File>, others: Vec<File>,
} }
#[once(time = 60)] #[cached(time = 60)]
fn build_page(config: Config) -> String { fn build_page(config: Config, lang: Lang) -> String {
let contacts_dir = format!("{}/{}", config.locations.data_dir, CONTACT_DIR); let contacts_dir = format!("{}/{}", config.locations.data_dir, CONTACT_DIR);
let ext = ".md"; let ext = ".md";
// Get about // Get about
let about = read_file( let about = read_file_fallback(
FilePath { FilePath {
base: contacts_dir.clone(), base: contacts_dir.clone(),
path: "about.md".to_owned(), path: "about.md".to_owned(),
}, },
MType::Generic, MType::Generic,
lang,
); );
let mut socials = read(&FilePath { let mut socials = read(&FilePath {

View file

@ -60,13 +60,14 @@ fn get_content(
path: filename.to_owned(), path: filename.to_owned(),
}, },
MType::Cours, MType::Cours,
None,
) )
} }
fn build_page(info: &web::Query<PathRequest>, config: Config) -> String { fn build_page(info: &web::Query<PathRequest>, config: Config) -> String {
let cours_dir = "data/cours"; let cours_dir = "data/cours";
let (ep, el): (_, Vec<String>) = config let (ep, el): (_, Vec<_>) = config
.fc .fc
.exclude_courses .exclude_courses
.unwrap() .unwrap()

View file

@ -1,5 +1,5 @@
use actix_web::{get, web, Responder}; use actix_web::{get, web, HttpRequest, Responder};
use cached::proc_macro::once; use cached::proc_macro::cached;
use ramhorns::Content; use ramhorns::Content;
use crate::{ use crate::{
@ -8,13 +8,13 @@ use crate::{
utils::{ utils::{
markdown::{File, FilePath}, markdown::{File, FilePath},
metadata::MType, metadata::MType,
misc::{make_kw, read_file, Html}, misc::{lang, make_kw, read_file, read_file_fallback, Html, Lang},
}, },
}; };
#[get("/")] #[get("/")]
pub async fn page(config: web::Data<Config>) -> impl Responder { pub async fn page(req: HttpRequest, config: web::Data<Config>) -> impl Responder {
Html(build_page(config.get_ref().to_owned())) Html(build_page(config.get_ref().to_owned(), lang(req.headers())))
} }
#[derive(Content, Debug)] #[derive(Content, Debug)]
@ -34,14 +34,15 @@ struct StyleAvatar {
square: bool, square: bool,
} }
#[once(time = 60)] #[cached(time = 60)]
fn build_page(config: Config) -> String { fn build_page(config: Config, lang: Lang) -> String {
let mut file = read_file( let mut file = read_file_fallback(
FilePath { FilePath {
base: config.locations.data_dir, base: config.locations.data_dir.clone(),
path: "index.md".to_owned(), path: "index.md".to_owned(),
}, },
MType::Index, MType::Index,
lang,
); );
// Default values // Default values
@ -77,6 +78,7 @@ fn build_page(config: Config) -> String {
path: "README.md".to_owned(), path: "README.md".to_owned(),
}, },
MType::Generic, MType::Generic,
None,
); );
} }

View file

@ -1,5 +1,5 @@
use actix_web::{get, web, Responder}; use actix_web::{get, web, HttpRequest, Responder};
use cached::proc_macro::once; use cached::proc_macro::cached;
use glob::glob; use glob::glob;
use ramhorns::Content; use ramhorns::Content;
@ -9,13 +9,13 @@ use crate::{
utils::{ utils::{
markdown::{File, FilePath}, markdown::{File, FilePath},
metadata::MType, metadata::MType,
misc::{make_kw, read_file, Html}, misc::{lang, make_kw, read_file, read_file_fallback, Html, Lang},
}, },
}; };
#[get("/portfolio")] #[get("/portfolio")]
pub async fn page(config: web::Data<Config>) -> impl Responder { pub async fn page(req: HttpRequest, config: web::Data<Config>) -> impl Responder {
Html(build_page(config.get_ref().to_owned())) Html(build_page(config.get_ref().to_owned(), lang(req.headers())))
} }
#[derive(Content, Debug)] #[derive(Content, Debug)]
@ -29,8 +29,8 @@ struct PortfolioTemplate<'a> {
err_msg: &'a str, err_msg: &'a str,
} }
#[once(time = 60)] #[cached(time = 60)]
fn build_page(config: Config) -> String { fn build_page(config: Config, lang: Lang) -> String {
let projects_dir = format!("{}/projects", config.locations.data_dir); let projects_dir = format!("{}/projects", config.locations.data_dir);
let apps_dir = FilePath { let apps_dir = FilePath {
base: format!("{projects_dir}/apps"), base: format!("{projects_dir}/apps"),
@ -39,12 +39,13 @@ fn build_page(config: Config) -> String {
let ext = ".md"; let ext = ".md";
// Get about // Get about
let about = read_file( let about = read_file_fallback(
FilePath { FilePath {
base: projects_dir, base: projects_dir,
path: "about.md".to_owned(), path: "about.md".to_owned(),
}, },
MType::Generic, MType::Generic,
lang,
); );
// Get apps // Get apps
@ -54,6 +55,7 @@ fn build_page(config: Config) -> String {
read_file( read_file(
apps_dir.from(&e.unwrap().to_string_lossy()), apps_dir.from(&e.unwrap().to_string_lossy()),
MType::Portfolio, MType::Portfolio,
None,
) )
.unwrap() .unwrap()
}) })
@ -72,6 +74,7 @@ fn build_page(config: Config) -> String {
read_file( read_file(
apps_dir.from(&e.unwrap().to_string_lossy()), apps_dir.from(&e.unwrap().to_string_lossy()),
MType::Portfolio, MType::Portfolio,
None,
) )
.unwrap() .unwrap()
}) })

View file

@ -1,7 +1,7 @@
use ramhorns::{Content, Ramhorns}; use ramhorns::{Content, Ramhorns};
/// Structure used in the config variable of the app /// Structure used in the config variable of the app
#[derive(Clone, Debug)] #[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct Template { pub struct Template {
/// Root directory where templates are stored /// Root directory where templates are stored
pub directory: String, pub directory: String,

View file

@ -1,8 +1,10 @@
use std::{fs, os::unix::fs::MetadataExt, path::Path}; use std::{fs, os::unix::fs::MetadataExt, path::Path};
use actix_web::{ use actix_web::{
http::header::{self, ContentType, TryIntoHeaderValue}, http::{
http::StatusCode, header::{self, ContentType, HeaderMap, TryIntoHeaderValue},
StatusCode,
},
HttpRequest, HttpResponse, Responder, HttpRequest, HttpResponse, Responder,
}; };
use base64::{engine::general_purpose, Engine}; use base64::{engine::general_purpose, Engine};
@ -56,15 +58,33 @@ impl Responder for Html {
} }
} }
/// Read a file localized, fallback to default file if localized file isn't found
pub fn read_file_fallback(filename: FilePath, expected_file: MType, lang: Lang) -> Option<File> {
read_file(filename.clone(), expected_file, Some(lang)).or(read_file(
filename,
expected_file,
None,
))
}
/// Read a file /// Read a file
pub fn read_file(filename: FilePath, expected_file: MType) -> Option<File> { pub fn read_file(filename: FilePath, expected_file: MType, lang: Option<Lang>) -> Option<File> {
reader(filename, expected_file) reader(filename, expected_file, lang.unwrap_or(Lang::English))
} }
#[cached(time = 600)] #[cached(time = 600)]
fn reader(filename: FilePath, expected_file: MType) -> Option<File> { fn reader(filename: FilePath, expected_file: MType, lang: Lang) -> Option<File> {
let as_str = filename.to_string(); let as_str = match lang {
let path = Path::new(&as_str); Lang::French => {
let str = filename.to_string();
let mut parts = str.split('.').collect::<Vec<_>>();
let extension = parts.pop().unwrap_or("");
let filename = parts.join(".");
&format!("{filename}-fr.{extension}")
}
Lang::English => &filename.to_string(),
};
let path = Path::new(as_str);
if let Ok(metadata) = path.metadata() { if let Ok(metadata) = path.metadata() {
// Taille maximale : 30M // Taille maximale : 30M
@ -76,12 +96,12 @@ fn reader(filename: FilePath, expected_file: MType) -> Option<File> {
path.extension().and_then(|ext| { path.extension().and_then(|ext| {
match mime_guess::from_ext(ext.to_str().unwrap_or_default()).first_or_text_plain() { match mime_guess::from_ext(ext.to_str().unwrap_or_default()).first_or_text_plain() {
mime if mime == mime::APPLICATION_PDF => { mime if mime == mime::APPLICATION_PDF => {
fs::read(&as_str).map_or(None, |bytes| Some(read_pdf(bytes))) fs::read(as_str).map_or(None, |bytes| Some(read_pdf(bytes)))
} }
mime if mime.type_() == mime::IMAGE => { mime if mime.type_() == mime::IMAGE => {
fs::read(&as_str).map_or(None, |bytes| Some(read_img(bytes, &mime))) fs::read(as_str).map_or(None, |bytes| Some(read_img(bytes, &mime)))
} }
_ => fs::read_to_string(&as_str).map_or(None, |text| { _ => fs::read_to_string(as_str).map_or(None, |text| {
Some(read_md(&filename, &text, expected_file, None, true)) Some(read_md(&filename, &text, expected_file, None, true))
}), }),
} }
@ -116,3 +136,23 @@ fn read_img(data: Vec<u8>, mime: &mime::Mime) -> File {
pub fn remove_first_letter(s: &str) -> &str { pub fn remove_first_letter(s: &str) -> &str {
s.chars().next().map(|c| &s[c.len_utf8()..]).unwrap() s.chars().next().map(|c| &s[c.len_utf8()..]).unwrap()
} }
#[derive(Hash, PartialEq, Eq, Clone)]
pub enum Lang {
French,
English,
}
/// Get the browser language
pub fn lang(headers: &HeaderMap) -> Lang {
headers
.get("Accept-Language")
.and_then(|lang| lang.to_str().ok())
.and_then(|lang| {
["fr", "fr-FR"]
.into_iter()
.any(|i| lang.contains(i))
.then_some(Lang::French)
})
.unwrap_or(Lang::English)
}

View file

@ -51,6 +51,7 @@ impl Post {
path: format!("{}{ext}", self.url), path: format!("{}{ext}", self.url),
}, },
MType::Blog, MType::Blog,
None,
) { ) {
self.content = Some(file.content); self.content = Some(file.content);
} }
@ -149,6 +150,7 @@ pub fn get_post(
path: format!("{filename}{ext}"), path: format!("{filename}{ext}"),
}, },
MType::Blog, MType::Blog,
None,
); );
let default = ( let default = (

View file

@ -57,7 +57,14 @@ pub fn remove_paragraphs(list: &mut [File]) {
pub fn read(path: &FilePath) -> Vec<File> { pub fn read(path: &FilePath) -> Vec<File> {
glob(&path.to_string()) glob(&path.to_string())
.unwrap() .unwrap()
.map(|e| read_file(path.from(&e.unwrap().to_string_lossy()), MType::Contact).unwrap()) .map(|e| {
read_file(
path.from(&e.unwrap().to_string_lossy()),
MType::Contact,
None,
)
.unwrap()
})
.filter(|f| { .filter(|f| {
!f.metadata !f.metadata
.info .info