Skip to content

Commit 3ae07a1

Browse files
committed
Create renderer
1 parent 057547e commit 3ae07a1

File tree

11 files changed

+1309
-7
lines changed

11 files changed

+1309
-7
lines changed

Cargo.lock

Lines changed: 691 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,17 @@ description = "Plugins for a mdbook translation workflow based on Gettext."
1111

1212
[dependencies]
1313
anyhow = "1.0.68"
14+
lol_html = "1.2.0"
1415
mdbook = { version = "0.4.25", default-features = false }
1516
polib = "0.2.0"
1617
pulldown-cmark = { version = "0.9.2", default-features = false }
1718
pulldown-cmark-to-cmark = "10.0.4"
1819
regex = "1.9.4"
1920
semver = "1.0.16"
21+
serde = "1.0.130"
2022
serde_json = "1.0.91"
23+
tera = "1.19.1"
24+
thiserror = "1.0.30"
2125

2226
[dev-dependencies]
2327
pretty_assertions = "1.3.0"
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
use super::error::RendererError;
2+
use super::CustomComponent;
3+
use crate::custom_component_renderer::error::Result;
4+
use lol_html::html_content::ContentType;
5+
use lol_html::{element, RewriteStrSettings};
6+
use std::fs;
7+
use std::io::{Read, Write};
8+
use std::path::{Path, PathBuf};
9+
10+
use std::collections::BTreeMap;
11+
12+
use serde::Deserialize;
13+
14+
/// Configuration specific to the i18n-helpers component.
15+
#[derive(Deserialize, Debug)]
16+
pub struct I18nConfiguration {
17+
pub languages: BTreeMap<String, String>,
18+
pub default_language: Option<String>,
19+
#[serde(default)]
20+
pub translate_all_languages: bool,
21+
}
22+
23+
pub struct RenderingContext<'a> {
24+
pub path: PathBuf,
25+
pub language: String,
26+
pub i18_config: &'a I18nConfiguration,
27+
pub language_to_rendered_path: BTreeMap<String, PathBuf>,
28+
}
29+
30+
impl<'a> RenderingContext<'a> {
31+
fn new(
32+
path: PathBuf,
33+
book_dir: PathBuf,
34+
language: String,
35+
i18_config: &'a I18nConfiguration,
36+
) -> Result<Self> {
37+
let html_dir = book_dir.join("html");
38+
let mut language_to_rendered_path: BTreeMap<String, PathBuf> = BTreeMap::new();
39+
for identifier in i18_config.languages.keys() {
40+
let mut relative_path = path.strip_prefix(&html_dir)?.to_owned();
41+
if let Ok(without_lang) = relative_path.strip_prefix(&language) {
42+
relative_path = without_lang.to_owned();
43+
}
44+
if Some(identifier) != i18_config.default_language.as_ref() {
45+
relative_path = Path::new(identifier).join(relative_path).to_owned();
46+
}
47+
language_to_rendered_path.insert(
48+
identifier.clone(),
49+
Path::new("/").join(relative_path).to_owned(),
50+
);
51+
}
52+
Ok(RenderingContext {
53+
path,
54+
language,
55+
i18_config,
56+
language_to_rendered_path,
57+
})
58+
}
59+
}
60+
61+
pub(crate) struct BookDirectoryRenderer {
62+
config: I18nConfiguration,
63+
book: mdbook::MDBook,
64+
book_dir: PathBuf,
65+
components: Vec<CustomComponent>,
66+
languages_paths: BTreeMap<String, PathBuf>,
67+
}
68+
69+
impl BookDirectoryRenderer {
70+
pub(crate) fn new(
71+
config: I18nConfiguration,
72+
book: mdbook::MDBook,
73+
book_dir: PathBuf,
74+
) -> BookDirectoryRenderer {
75+
let default_language = config.default_language.clone();
76+
let languages_paths = config
77+
.languages
78+
.keys()
79+
.filter(|language| {
80+
default_language.is_none() || *language != default_language.as_ref().unwrap()
81+
})
82+
.map(|language| (language.clone(), book_dir.join("html").join(language)))
83+
.collect::<BTreeMap<String, PathBuf>>();
84+
BookDirectoryRenderer {
85+
config,
86+
book,
87+
languages_paths,
88+
book_dir,
89+
components: Vec::new(),
90+
}
91+
}
92+
93+
pub fn translate(&mut self) -> Result<()> {
94+
let default_language = &self.config.default_language;
95+
let original_language = self.book.config.book.language.clone();
96+
let book_dir = self.book_dir.as_path();
97+
98+
for identifier in self.config.languages.keys() {
99+
if let Some(default_language) = default_language {
100+
if default_language == identifier {
101+
continue;
102+
}
103+
}
104+
105+
let translation_path = book_dir.join(identifier);
106+
107+
self.book.config.book.language = Some(identifier.clone());
108+
self.book.config.book.multilingual = true;
109+
self.book.config.build.build_dir = translation_path;
110+
self.book.build()?;
111+
std::fs::rename(
112+
book_dir.join(identifier).join("html"),
113+
book_dir.join("html").join(identifier),
114+
)?;
115+
}
116+
self.book.config.book.language = original_language;
117+
self.book.config.build.build_dir = book_dir.to_owned();
118+
Ok(())
119+
}
120+
121+
pub(crate) fn render_book(&mut self) -> Result<()> {
122+
let html_dir = self.book_dir.join("html");
123+
if !html_dir.is_dir() {
124+
return Err(RendererError::InvalidPath(format!(
125+
"{:?} is not a directory",
126+
self.book_dir
127+
)));
128+
}
129+
self.render_book_directory(&html_dir)
130+
}
131+
132+
pub(crate) fn add_component(&mut self, component: CustomComponent) {
133+
self.components.push(component);
134+
}
135+
136+
fn extract_language_from_path(&self, path: &Path) -> String {
137+
for (language, language_path) in &self.languages_paths {
138+
if path.starts_with(language_path) {
139+
return language.clone();
140+
}
141+
}
142+
self.config.default_language.clone().unwrap_or_default()
143+
}
144+
145+
fn render_components(&mut self, file_content: &str, path: &Path) -> Result<String> {
146+
let rendering_context = RenderingContext::new(
147+
path.to_owned(),
148+
self.book_dir.clone(),
149+
self.extract_language_from_path(path),
150+
&self.config,
151+
)?;
152+
let custom_components_handlers = self
153+
.components
154+
.iter()
155+
.map(|component| {
156+
element!(component.component_name(), |el| {
157+
let rendered = component.render(&rendering_context)?;
158+
el.replace(&rendered, ContentType::Html);
159+
Ok(())
160+
})
161+
})
162+
.collect();
163+
let output = lol_html::rewrite_str(
164+
file_content,
165+
RewriteStrSettings {
166+
element_content_handlers: custom_components_handlers,
167+
..RewriteStrSettings::default()
168+
},
169+
)?;
170+
Ok(output)
171+
}
172+
173+
fn process_file(&mut self, path: &Path) -> Result<()> {
174+
if path.extension().unwrap_or_default() != "html" {
175+
return Ok(());
176+
}
177+
let mut file_content = String::new();
178+
{
179+
let mut file = fs::File::open(path)?;
180+
file.read_to_string(&mut file_content)?;
181+
}
182+
183+
let output = self.render_components(&file_content, path)?;
184+
let mut output_file = fs::File::create(path)?;
185+
output_file.write_all(output.as_bytes())?;
186+
Ok(())
187+
}
188+
189+
fn render_book_directory(&mut self, path: &Path) -> Result<()> {
190+
for entry in path.read_dir()? {
191+
let entry = entry?;
192+
let path = entry.path();
193+
if path.is_dir() {
194+
self.render_book_directory(&path)?;
195+
} else {
196+
self.process_file(&path)?;
197+
}
198+
}
199+
Ok(())
200+
}
201+
}
202+
203+
#[cfg(test)]
204+
mod tests {
205+
use crate::custom_component_renderer::standard_templates;
206+
207+
const FAKE_BOOK_TOML: &str = r#"
208+
[book]
209+
src = "src"
210+
211+
[rust]
212+
edition = "2021"
213+
214+
[build]
215+
extra-watch-dirs = ["po", "third_party"]
216+
217+
[preprocessor.gettext]
218+
after = ["links"]
219+
220+
[preprocessor.svgbob]
221+
renderers = ["html"]
222+
after = ["gettext"]
223+
class = "bob"
224+
225+
[output.html]
226+
curly-quotes = true
227+
228+
[output.i18n-helpers]
229+
default_language = "en"
230+
translate_all_languages = false
231+
232+
[output.i18n-helpers.languages]
233+
"en" = "English"
234+
"es" = "Spanish (Español)"
235+
"ko" = "Korean (한국어)"
236+
"pt-BR" = "Brazilian Portuguese (Português do Brasil)"
237+
"#;
238+
239+
#[test]
240+
fn test_render_book() {
241+
use super::*;
242+
use std::fs::File;
243+
use tempfile::tempdir;
244+
245+
const INITIAL_HTML: &[u8] = b"<html><body><LanguagePicker/></body></html>";
246+
247+
let dir = tempdir().unwrap();
248+
std::fs::create_dir(dir.path().join("html")).expect("Failed to create html directory");
249+
std::fs::create_dir(dir.path().join("src")).expect("Failed to create src directory");
250+
251+
std::fs::write(dir.path().join("html/test.html"), INITIAL_HTML)
252+
.expect("Failed to write initial html");
253+
std::fs::write(dir.path().join("book.toml"), FAKE_BOOK_TOML)
254+
.expect("Failed to write initial book.toml");
255+
std::fs::write(dir.path().join("src/SUMMARY.md"), "")
256+
.expect("Failed to write initial SUMMARY.md");
257+
258+
let mut languages = BTreeMap::new();
259+
languages.insert(String::from("en"), String::from("English"));
260+
languages.insert(String::from("fr"), String::from("French"));
261+
let mock_config = I18nConfiguration {
262+
languages,
263+
default_language: Some(String::from("en")),
264+
translate_all_languages: true,
265+
};
266+
let mdbook = mdbook::MDBook::load(&dir.path()).expect("Failed to load book");
267+
268+
let mut renderer = BookDirectoryRenderer::new(mock_config, mdbook, dir.path().to_owned());
269+
renderer.add_component(standard_templates::create_language_picker_component());
270+
renderer.render_book().expect("Failed to render book");
271+
272+
let mut output = String::new();
273+
let mut file = File::open(dir.path().join("html/test.html")).unwrap();
274+
file.read_to_string(&mut output).unwrap();
275+
276+
const EXPECTED: &str = "<html><body><button id=\"language-toggle0\" class=\"icon-button\" type=\"button\"\n title=\"Change language\" aria-label=\"Change language\"\n aria-haspopup=\"true\" aria-expanded=\"false\"\n aria-controls=\"language-list0\">\n <i class=\"fa fa-globe\"></i>\n</button>\n<ul id=\"language-list0\" class=\"theme-popup\" aria-label=\"Languages\"\n role=\"menu\" style=\"left: auto; right: 10px;\">\n \n <li role=\"none\">\n <a id=\"en\"\n href=\"/test.html\"\n style=\"color: inherit;\">\n <button role=\"menuitem\" class=\"theme theme-selected \">\n English\n </button>\n </a>\n </li>\n \n <li role=\"none\">\n <a id=\"fr\"\n href=\"/fr/test.html\"\n style=\"color: inherit;\">\n <button role=\"menuitem\" class=\"theme \">\n French\n </button>\n </a>\n </li>\n \n</ul>\n\n<script>\n let langToggle = document.getElementById(\"language-toggle0\");\n let langList = document.getElementById(\"language-list0\");\n \n langToggle.addEventListener(\"click\", (event) => {{\n langList.style.display = langList.style.display == \"block\" ? \"none\" : \"block\";\n }});\n \n</script>\n\n<style>\n [dir=rtl] #language-list0 {\n left: 10px;\n right: auto;\n }\n \n</style>\n</html>";
277+
278+
assert_eq!(output, EXPECTED);
279+
}
280+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
use std::cell::RefCell;
2+
use std::collections::{BTreeMap, HashMap};
3+
use std::path::PathBuf;
4+
5+
use serde_json::{from_value, to_value};
6+
use tera::Tera;
7+
8+
use crate::Result;
9+
10+
use super::RenderingContext;
11+
12+
pub struct CustomComponent {
13+
template: String,
14+
name: String,
15+
id: RefCell<u32>,
16+
}
17+
18+
impl CustomComponent {
19+
pub fn new(name: &str, template: &str) -> CustomComponent {
20+
CustomComponent {
21+
name: String::from(name),
22+
template: String::from(template),
23+
id: RefCell::new(0),
24+
}
25+
}
26+
27+
fn make_language_to_rendered_path_function(
28+
language_to_rendered_path: BTreeMap<String, PathBuf>,
29+
) -> impl tera::Function {
30+
Box::new(
31+
move |args: &HashMap<String, tera::Value>| -> tera::Result<tera::Value> {
32+
match args.get("identifier") {
33+
Some(val) => match from_value::<String>(val.clone()) {
34+
Ok(v) => Ok(to_value(language_to_rendered_path.get(&v).ok_or_else(
35+
|| {
36+
tera::Error::from(
37+
"No language with the provided indentifier was found",
38+
)
39+
},
40+
)?)?),
41+
Err(_) => Err("Failed to deserialize argument".into()),
42+
},
43+
None => Err("Identifier argument not provided".into()),
44+
}
45+
},
46+
)
47+
}
48+
49+
fn create_context(
50+
&self,
51+
tera_obj: &mut Tera,
52+
rendering_context: &RenderingContext,
53+
) -> tera::Context {
54+
let id = self.id.replace_with(|&mut id| id + 1);
55+
let mut context = tera::Context::new();
56+
context.insert("id", &id);
57+
if rendering_context.language != "en" {
58+
println!("LANGUAGE {}", rendering_context.language);
59+
}
60+
61+
context.insert("language", &rendering_context.language);
62+
context.insert(
63+
"default_language",
64+
&rendering_context.i18_config.default_language,
65+
);
66+
context.insert("languages", &rendering_context.i18_config.languages);
67+
context.insert("path", &rendering_context.path);
68+
69+
let language_to_rendered_path = rendering_context.language_to_rendered_path.clone();
70+
let language_to_rendered_path_function =
71+
CustomComponent::make_language_to_rendered_path_function(language_to_rendered_path);
72+
tera_obj.register_function(
73+
"language_to_rendered_path",
74+
language_to_rendered_path_function,
75+
);
76+
77+
context
78+
}
79+
80+
pub fn render(&self, rendering_context: &RenderingContext) -> Result<String> {
81+
let mut tera = Tera::default();
82+
tera.add_raw_template("template", &self.template)?;
83+
84+
let context = self.create_context(&mut tera, rendering_context);
85+
let output = tera.render("template", &context)?;
86+
Ok(output)
87+
}
88+
89+
pub fn component_name(&self) -> String {
90+
self.name.clone()
91+
}
92+
}

0 commit comments

Comments
 (0)