Playing around with a basic i18n bit of support

At first blush didn't find anything too wonderful out in the ecosystem so tried
my own hand at it
This commit is contained in:
R Tyler Croy 2021-07-04 13:05:34 -07:00
parent 983c9d0f47
commit 5337e8d7ca
No known key found for this signature in database
GPG Key ID: E5C92681BEF6CEA2
12 changed files with 2418 additions and 1 deletions

1
.ignore Normal file
View File

@ -0,0 +1 @@
views/

2044
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -6,10 +6,25 @@ authors = ["R Tyler Croy <rtyler@brokenco.de>"]
description = "The freshest butler in Bel-air"
[dependencies]
async-std = { version = "1", features = ["attributes", "tokio1"] }
async-std = { version = "1", features = ["attributes"] }
dotenv = "*"
# Used for traversing directory structures
glob = "0"
# For rendering simple HTTP views
handlebars = { version = "4", features = ["dir_source"] }
lazy_static = "1"
log = "*"
pretty_env_logger = "0.3"
regex = "1"
# Used for embedding templates
rust-embed = "5"
# All the object serialization for APIs and persisting data on disk
serde = { version = "1", features = ["derive"] }
serde_qs = "0.7"
serde_json = "1"
serde_yaml = "0.8"
#sqlx = { version = "0.5", features = ["chrono", "json", "offline", "postgres", "uuid", "runtime-async-std-rustls"] }
# Web framework
tide = "*"

4
READMe.adoc Normal file
View File

@ -0,0 +1,4 @@
= Geoffrey
Geoffrey is the freshest butler you've ever met and is here to provide a simple
and reliable continuous integration experience.

4
i18n/de.yml Normal file
View File

@ -0,0 +1,4 @@
---
actions:
run: 'Lauf'
edit: 'Bearbeiten'

5
i18n/en.yml Normal file
View File

@ -0,0 +1,5 @@
---
banner: 'Welcome to Geoffrey'
actions:
run: 'Run'
edit: 'Edit'

66
src/dao.rs Normal file
View File

@ -0,0 +1,66 @@
/**
* The dao module contains all the necessary object model definitions for Geoffrey to store data on
* disk
*/
/**
* The v1 module contains the initial version of data access objects/models
*/
pub mod v1 {
use glob::glob;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct Project {
/// The specific type of SCM for the project
scm: ScmType,
/// The name of the project
name: String,
/// The URL slug for the project
slug: String,
/// A user-friendly plaintext or markdown description of the project
description: Option<String>,
/// The trigger for executing the project
trigger: TriggerType,
}
impl Project {
pub fn load_all() -> Vec<Project> {
use std::fs::File;
let mut projects = vec![];
for entry in glob("projects.d/**/*.yml").expect("Failed to read projects glob") {
match entry {
Ok(path) => {
projects.push(
serde_yaml::from_reader(File::open(path).expect("Failed to open path"))
.expect("Failed to load")
);
},
Err(e) => println!("{:?}", e),
}
}
projects
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum ScmType {
Git {
url: String,
},
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub enum TriggerType {
Manual,
Cron {
schedule: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
}
}

81
src/i18n.rs Normal file
View File

@ -0,0 +1,81 @@
/**
* The i18n module provides some simple internationalization support
* in a way that is compatible with handlebars rendering
*/
use log::*;
pub fn parse_languages(header: &str) -> Vec<Language> {
trace!("Parsing languages from: {}", header);
let mut results = vec![];
for part in header.split(",") {
if let Ok(language) = Language::from(part) {
results.push(language);
}
}
results
}
#[derive(Clone, Debug)]
pub struct Language {
pub code: String,
region: Option<String>,
quality: f64,
}
lazy_static! {
static ref LANG_REGEX: regex::Regex = regex::Regex::new(r"(?P<code>\w+)-?(?P<region>\w+)?(;q=(?P<quality>([0-9]*[.])?[0-9]+)?)?").unwrap();
}
impl Language {
fn from(segment: &str) -> Result<Language, Error> {
if let Some(captures) = LANG_REGEX.captures(segment) {
println!("caps: {:?}", captures);
Ok(Language {
code: captures.name("code").map_or("unknown".to_string(), |c| c.as_str().to_string()),
region: captures.name("region").map_or(None, |c| Some(c.as_str().to_string())),
quality: captures.name("quality").map_or(1.0, |c| c.as_str().parse().unwrap_or(0.0)),
})
}
else {
Err(Error::Generic)
}
}
}
#[derive(Clone, Debug)]
enum Error {
Generic,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_from_segment() {
let lang = Language::from("en-US");
assert!(lang.is_ok());
let lang = lang.unwrap();
assert_eq!("en", lang.code);
assert_eq!(Some("US".to_string()), lang.region);
assert_eq!(1.0, lang.quality);
}
#[test]
fn parse_langs_simple() {
let header = "en-US,en;q=0.5";
let langs = parse_languages(&header);
assert_eq!(langs.len(), 2);
}
#[test]
fn parse_langs_multi() {
let header = "en-US,en;q=0.7,de-DE;q=0.3";
let langs = parse_languages(&header);
assert_eq!(langs.len(), 3);
let de = langs.get(2).unwrap();
assert_eq!("de", de.code);
assert_eq!(0.3, de.quality);
}
}

View File

@ -1,7 +1,21 @@
/**
* This module contains the main entrypoint for Geoffrey
*/
#[macro_use]
extern crate serde_json;
#[macro_use]
extern crate lazy_static;
use log::*;
mod dao;
mod i18n;
mod state;
#[async_std::main]
async fn main() -> Result<(), std::io::Error> {
use crate::state::AppState;
dotenv::dotenv().ok();
pretty_env_logger::init();
@ -10,6 +24,24 @@ async fn main() -> Result<(), std::io::Error> {
info!("Activating DEBUG mode configuration");
}
let state = AppState::new();
let mut app = tide::with_state(state);
app.at("/")
.get(|req: tide::Request<AppState<'static>>| async move {
let data = json!({
"projects" : dao::v1::Project::load_all(),
});
let lang = match req.header("Accept-Language") {
Some(l) => l.as_str(),
None => "en",
};
let langs = crate::i18n::parse_languages(lang);
info!("Lang: {:?}", langs);
req.state().render("index", &langs, Some(data)).await
});
if let Some(fd) = std::env::var("LISTEN_FD")
.ok()
.and_then(|fd| fd.parse().ok())

120
src/state.rs Normal file
View File

@ -0,0 +1,120 @@
use async_std::sync::{Arc, RwLock};
use handlebars::Handlebars;
use log::*;
use std::fs::File;
use std::path::Path;
use crate::i18n::Language;
#[derive(Clone, Debug)]
pub struct AppState<'a> {
hb: Arc<RwLock<Handlebars<'a>>>,
}
impl AppState<'_> {
pub fn new() -> Self {
let mut hb = Handlebars::new();
// This ensures that we get errors rather than empty strings for bad values
hb.set_strict_mode(true);
Self {
hb: Arc::new(RwLock::new(hb)),
}
}
pub async fn register_templates(&self) -> Result<(), handlebars::TemplateError> {
let mut hb = self.hb.write().await;
hb.clear_templates();
hb.register_templates_directory(".hbs", "views")
}
pub async fn render(
&self,
name: &str,
langs: &Vec<Language>,
data: Option<serde_json::Value>,
) -> Result<tide::Body, tide::Error> {
/*
* In debug mode, reload the templates on ever render to avoid
* needing a restart
*/
#[cfg(debug_assertions)]
{
self.register_templates().await?;
}
if let Some(data) = &data {
if ! data.is_object() {
warn!("The render function was called without a map, this can lead to funny results");
}
}
let mut i18n = serde_yaml::Value::Null;
// merge in the langages from lowest priority
for lang in langs.iter().rev() {
let filename = format!("i18n/{}.yml", lang.code);
if ! Path::exists(Path::new(&filename)) {
continue;
}
let t: serde_yaml::Value = serde_yaml::from_reader(File::open(filename)?)?;
merge_yaml(&mut i18n, t);
}
let mut mustache = json!({"t" : i18n});
merge(&mut mustache, data.unwrap_or(serde_json::Value::Null));
let hb = self.hb.read().await;
match hb.render(name, &mustache) {
Ok(view) => {
let mut body = tide::Body::from_string(view);
body.set_mime("text/html");
Ok(body)
},
Err(e) => {
error!("Failed to render {}: {:?}", name, e);
Err(e.into())
},
}
}
}
fn merge(a: &mut serde_json::Value, b: serde_json::Value) {
match (a, b) {
(a @ &mut serde_json::Value::Object(_), serde_json::Value::Object(b)) => {
let a = a.as_object_mut().unwrap();
for (k, v) in b {
if v.is_array() && a.contains_key(&k) && a.get(&k).as_ref().unwrap().is_array() {
let mut _a = a.get(&k).unwrap().as_array().unwrap().to_owned();
_a.append(&mut v.as_array().unwrap().to_owned());
a[&k] = serde_json::Value::from(_a);
}
else{
merge(a.entry(k).or_insert(serde_json::Value::Null), v);
}
}
}
(a, b) => *a = b,
}
}
fn merge_yaml(a: &mut serde_yaml::Value, b: serde_yaml::Value) {
match (a, b) {
(a @ &mut serde_yaml::Value::Mapping(_), serde_yaml::Value::Mapping(b)) => {
let a = a.as_mapping_mut().unwrap();
for (k, v) in b {
if v.is_sequence() && a.contains_key(&k) && a[&k].is_sequence() {
let mut _b = a.get(&k).unwrap().as_sequence().unwrap().to_owned();
_b.append(&mut v.as_sequence().unwrap().to_owned());
a[&k] = serde_yaml::Value::from(_b);
continue;
}
if !a.contains_key(&k) {a.insert(k.to_owned(), v.to_owned());}
else { merge_yaml(&mut a[&k], v); }
}
}
(a, b) => *a = b,
}
}

0
views/.gitignore vendored Normal file
View File

45
views/index.hbs Normal file
View File

@ -0,0 +1,45 @@
<html>
<head>
<title>Geoffrey</title>
</head>
<body>
<div class="banner">
{{t.banner}}
</div>
<div class="projects">
<ul>
{{#each projects}}
<li>
<a href="project/{{slug}}">
{{name}}
</a>
<p>
{{description}}
</p>
<div class="actions">
<ul>
<li>
<a href="project/{{slug}}/run">
{{../t.actions.run}}
</a>
</li>
<li>
<a href="project/{{slug}}/edit">
{{../t.actions.edit}}
</a>
</li>
</ul>
</div>
</li>
{{/each}}
</ul>
</div>
</body>
<!--
vim: ft=html
-->
</html>