Skip to content

Commit 55ced24

Browse files
committed
Create renderer
1 parent 057547e commit 55ced24

File tree

10 files changed

+1110
-7
lines changed

10 files changed

+1110
-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: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
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 mdbook::renderer::RenderContext;
7+
use serde_json::to_value;
8+
use std::collections::HashMap;
9+
use std::fs;
10+
use std::io::{Read, Write};
11+
use std::path::{Path, PathBuf};
12+
use std::sync::Arc;
13+
14+
pub struct RenderingContext<'a> {
15+
pub path: PathBuf,
16+
pub language: Option<String>,
17+
pub serialized_ctx: &'a serde_json::Value,
18+
pub ctx: &'a RenderContext,
19+
}
20+
21+
impl<'a> RenderingContext<'a> {
22+
fn new(
23+
path: PathBuf,
24+
language: Option<String>,
25+
serialized_ctx: &'a serde_json::Value,
26+
ctx: &'a RenderContext,
27+
) -> Result<Self> {
28+
Ok(RenderingContext {
29+
path,
30+
language,
31+
serialized_ctx,
32+
ctx,
33+
})
34+
}
35+
}
36+
37+
pub(crate) struct BookDirectoryRenderer {
38+
ctx: Arc<RenderContext>,
39+
serialized_ctx: serde_json::Value,
40+
components: Vec<CustomComponent>,
41+
}
42+
43+
impl BookDirectoryRenderer {
44+
pub(crate) fn new(ctx: RenderContext) -> Result<BookDirectoryRenderer> {
45+
Ok(BookDirectoryRenderer {
46+
serialized_ctx: serde_json::to_value(&ctx)?,
47+
ctx: Arc::new(ctx),
48+
components: Vec::new(),
49+
})
50+
}
51+
52+
pub(crate) fn render_book(&mut self) -> Result<()> {
53+
let dest_dir = &self
54+
.ctx
55+
.destination
56+
.parent()
57+
.ok_or_else(|| {
58+
RendererError::InvalidPath(format!(
59+
"Destination directory {:?} has no parent",
60+
self.ctx.destination
61+
))
62+
})?
63+
.to_owned();
64+
if !dest_dir.is_dir() {
65+
return Err(RendererError::InvalidPath(format!(
66+
"{:?} is not a directory",
67+
dest_dir
68+
)));
69+
}
70+
self.render_book_directory(&dest_dir)
71+
}
72+
73+
fn create_get_context_function(&self) -> impl tera::Function {
74+
let ctx_rx = Arc::clone(&self.ctx);
75+
move |args: &HashMap<String, serde_json::value::Value>| -> tera::Result<tera::Value> {
76+
let key = args
77+
.get("key")
78+
.ok_or_else(|| tera::Error::from(format!("No key argument provided")))?
79+
.as_str()
80+
.ok_or_else(|| {
81+
tera::Error::from(format!("Key has invalid type, expected string"))
82+
})?;
83+
let value = ctx_rx
84+
.config
85+
.get(key)
86+
.ok_or_else(|| tera::Error::from(format!("Could not find key {key} in config")))?;
87+
let value = to_value(value)?;
88+
Ok(value)
89+
}
90+
}
91+
92+
pub(crate) fn add_component(&mut self, mut component: CustomComponent) {
93+
component.register_function("get_context", self.create_get_context_function());
94+
self.components.push(component);
95+
}
96+
97+
fn render_components(&mut self, file_content: &str, path: &Path) -> Result<String> {
98+
let rendering_context = RenderingContext::new(
99+
path.to_owned(),
100+
self.ctx.config.book.language.clone(),
101+
&self.serialized_ctx,
102+
&self.ctx,
103+
)?;
104+
let custom_components_handlers = self
105+
.components
106+
.iter()
107+
.map(|component| {
108+
element!(component.component_name(), |el| {
109+
let rendered = component.render(&rendering_context)?;
110+
el.replace(&rendered, ContentType::Html);
111+
Ok(())
112+
})
113+
})
114+
.collect();
115+
let output = lol_html::rewrite_str(
116+
file_content,
117+
RewriteStrSettings {
118+
element_content_handlers: custom_components_handlers,
119+
..RewriteStrSettings::default()
120+
},
121+
)?;
122+
Ok(output)
123+
}
124+
125+
fn process_file(&mut self, path: &Path) -> Result<()> {
126+
if path.extension().unwrap_or_default() != "html" {
127+
return Ok(());
128+
}
129+
let mut file_content = String::new();
130+
{
131+
let mut file = fs::File::open(path)?;
132+
file.read_to_string(&mut file_content)?;
133+
}
134+
135+
let output = self.render_components(&file_content, path)?;
136+
let mut output_file = fs::File::create(path)?;
137+
output_file.write_all(output.as_bytes())?;
138+
Ok(())
139+
}
140+
141+
fn render_book_directory(&mut self, path: &Path) -> Result<()> {
142+
for entry in path.read_dir()? {
143+
let entry = entry?;
144+
let path = entry.path();
145+
if path.is_dir() {
146+
self.render_book_directory(&path)?;
147+
} else {
148+
self.process_file(&path)?;
149+
}
150+
}
151+
Ok(())
152+
}
153+
}
154+
155+
#[cfg(test)]
156+
mod tests {
157+
use crate::custom_component_renderer::standard_templates;
158+
159+
const FAKE_BOOK_TOML: &str = r#"
160+
[book]
161+
src = "src"
162+
163+
[rust]
164+
edition = "2021"
165+
166+
[build]
167+
extra-watch-dirs = ["po", "third_party"]
168+
169+
[preprocessor.gettext]
170+
after = ["links"]
171+
172+
[preprocessor.svgbob]
173+
renderers = ["html"]
174+
after = ["gettext"]
175+
class = "bob"
176+
177+
[output.html]
178+
curly-quotes = true
179+
180+
[output.i18n]
181+
default_language = "en"
182+
translate_all_languages = false
183+
184+
[output.i18n.languages]
185+
"en" = "English"
186+
"es" = "Spanish (Español)"
187+
"ko" = "Korean (한국어)"
188+
"pt-BR" = "Brazilian Portuguese (Português do Brasil)"
189+
"#;
190+
191+
#[test]
192+
fn test_render_book() {
193+
use super::*;
194+
use std::fs::File;
195+
use tempfile::tempdir;
196+
197+
const INITIAL_HTML: &[u8] = b"<html><body><LanguagePicker/></body></html>";
198+
199+
let dir = tempdir().unwrap();
200+
std::fs::create_dir(dir.path().join("html")).expect("Failed to create html directory");
201+
std::fs::create_dir(dir.path().join("src")).expect("Failed to create src directory");
202+
203+
std::fs::write(dir.path().join("html/test.html"), INITIAL_HTML)
204+
.expect("Failed to write initial html");
205+
std::fs::write(dir.path().join("book.toml"), FAKE_BOOK_TOML)
206+
.expect("Failed to write initial book.toml");
207+
std::fs::write(dir.path().join("src/SUMMARY.md"), "")
208+
.expect("Failed to write initial SUMMARY.md");
209+
210+
let mdbook = mdbook::MDBook::load(dir.path()).expect("Failed to load mdbook");
211+
let ctx = RenderContext::new(
212+
dir.path(),
213+
mdbook.book,
214+
mdbook.config,
215+
dir.path().join("i18n-helpers"),
216+
);
217+
218+
let mut renderer = BookDirectoryRenderer::new(ctx).expect("Failed to create renderer");
219+
renderer.add_component(standard_templates::create_language_picker_component());
220+
renderer.render_book().expect("Failed to render book");
221+
222+
let mut output = String::new();
223+
let mut file = File::open(dir.path().join("html/test.html")).unwrap();
224+
file.read_to_string(&mut output).unwrap();
225+
226+
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=\"es\"\n href=\"/es/test.html\"\n style=\"color: inherit;\">\n <button role=\"menuitem\" class=\"theme \">\n Spanish (Español)\n </button>\n </a>\n </li>\n \n <li role=\"none\">\n <a id=\"ko\"\n href=\"/ko/test.html\"\n style=\"color: inherit;\">\n <button role=\"menuitem\" class=\"theme \">\n Korean (한국어)\n </button>\n </a>\n </li>\n \n <li role=\"none\">\n <a id=\"pt-BR\"\n href=\"/pt-BR/test.html\"\n style=\"color: inherit;\">\n <button role=\"menuitem\" class=\"theme \">\n Brazilian Portuguese (Português do Brasil)\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>";
227+
228+
assert_eq!(output, EXPECTED);
229+
}
230+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
use std::cell::RefCell;
2+
use std::collections::HashMap;
3+
4+
use tera::Tera;
5+
6+
use crate::Result;
7+
8+
use super::RenderingContext;
9+
10+
const TEMPLATE_NAME: &str = "template";
11+
12+
fn make_strip_prefix_function() -> impl tera::Function {
13+
move |args: &HashMap<String, serde_json::value::Value>| -> tera::Result<tera::Value> {
14+
let string = args
15+
.get("s")
16+
.ok_or_else(|| tera::Error::from(format!("No s argument provided")))?
17+
.as_str()
18+
.ok_or_else(|| tera::Error::from(format!("S has invalid type, expected string")))?;
19+
let prefix = args
20+
.get("prefix")
21+
.ok_or_else(|| tera::Error::from(format!("No prefix argument provided")))?
22+
.as_str()
23+
.ok_or_else(|| {
24+
tera::Error::from(format!("Prefix has invalid type, expected string"))
25+
})?;
26+
string
27+
.strip_prefix(prefix)
28+
.map(|s| tera::Value::String(s.to_owned()))
29+
.ok_or_else(|| tera::Error::from(format!("Could not strip prefix")))
30+
}
31+
}
32+
33+
pub struct CustomComponent {
34+
template: Tera,
35+
name: String,
36+
/// Used to generate unique ids for each component to prevent collisions in javascript with query selectors.
37+
counter: RefCell<u32>,
38+
}
39+
40+
impl CustomComponent {
41+
pub fn new(name: &str, template_str: &str) -> Result<CustomComponent> {
42+
let mut template = Tera::default();
43+
template.add_raw_template(TEMPLATE_NAME, template_str)?;
44+
template.register_function("strip_prefix", make_strip_prefix_function());
45+
Ok(CustomComponent {
46+
name: String::from(name),
47+
counter: RefCell::new(0),
48+
template,
49+
})
50+
}
51+
52+
pub fn register_function(&mut self, name: &str, function: impl tera::Function + 'static) {
53+
self.template.register_function(name, function);
54+
}
55+
56+
fn create_context(&self, rendering_context: &RenderingContext) -> tera::Context {
57+
let counter = self.counter.replace_with(|&mut counter| counter + 1);
58+
let mut context = tera::Context::new();
59+
context.insert("counter", &counter);
60+
context.insert("language", &rendering_context.language);
61+
context.insert("path", &rendering_context.path);
62+
context.insert("ctx", &rendering_context.serialized_ctx);
63+
context.insert(
64+
"book_dir",
65+
&rendering_context.ctx.destination.parent().unwrap(),
66+
);
67+
68+
context
69+
}
70+
71+
pub fn render(&self, rendering_context: &RenderingContext) -> Result<String> {
72+
let context = self.create_context(rendering_context);
73+
let output = self.template.render(TEMPLATE_NAME, &context)?;
74+
Ok(output)
75+
}
76+
77+
pub fn component_name(&self) -> String {
78+
self.name.clone()
79+
}
80+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
use std::path::StripPrefixError;
2+
3+
use thiserror::Error;
4+
5+
#[derive(Error, Debug)]
6+
pub enum RendererError {
7+
#[error("IO Error: {0}")]
8+
IoError(#[from] std::io::Error),
9+
#[error("Invalid path: {0}")]
10+
InvalidPath(String),
11+
#[error("Error rendering tera template: {0}")]
12+
TeraError(#[from] tera::Error),
13+
#[error("HTML Error: {0}")]
14+
HtmlRewritingError(#[from] lol_html::errors::RewritingError),
15+
#[error("Mdbook Error: {0}")]
16+
Mdbook(#[from] mdbook::errors::Error),
17+
#[error("Error in strip_prefix call: {0}")]
18+
StripPrefixError(#[from] StripPrefixError),
19+
#[error("Serde error: {0}")]
20+
SerdeError(#[from] serde_json::Error),
21+
}
22+
23+
pub type Result<T> = std::result::Result<T, RendererError>;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
mod book_directory_renderer;
2+
mod custom_component;
3+
mod error;
4+
pub(crate) mod standard_templates;
5+
6+
pub(crate) use book_directory_renderer::*;
7+
pub(crate) use custom_component::*;
8+
pub(crate) use error::*;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
use super::CustomComponent;
2+
3+
mod templates {
4+
pub const LANGUAGE_PICKER: &str = include_str!("standard_templates/language_picker.html");
5+
}
6+
7+
pub fn create_language_picker_component() -> CustomComponent {
8+
CustomComponent::new("LanguagePicker", templates::LANGUAGE_PICKER)
9+
.expect("Failed to create language picker component")
10+
}

0 commit comments

Comments
 (0)