diff --git a/Cargo.lock b/Cargo.lock index 4cd8b2d7..c135d12d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -172,6 +172,28 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "chrono-tz" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1369bc6b9e9a7dfdae2055f6ec151fe9c554a9d23d357c0237cee2e25eaabb7" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f5ebdc942f57ed96d560a6d1a459bae5851102a25d5bf89dc04ae453e31ecf" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + [[package]] name = "clap" version = "4.4.7" @@ -258,6 +280,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "deunicode" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95203a6a50906215a502507c0f879a0ce7ff205a6111e2db2a5ef8e4bb92e43" + [[package]] name = "diff" version = "0.1.13" @@ -325,6 +353,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "fuchsia-cprng" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" + [[package]] name = "generic-array" version = "0.14.7" @@ -335,6 +369,41 @@ dependencies = [ "version_check", ] +[[package]] +name = "getrandom" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4136b2a15dd319360be1c07d9933517ccf0be8f16bf62a3bee4f0d618df427" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "globset" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d" +dependencies = [ + "aho-corasick", + "bstr", + "fnv", + "log", + "regex", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "handlebars" version = "4.4.0" @@ -361,6 +430,15 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d77f7ec81a6d05a3abb01ab6eb7590f6083d08449fe5a1c8b1e620283546ccb7" +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + [[package]] name = "humantime" version = "2.1.0" @@ -390,6 +468,23 @@ dependencies = [ "cc", ] +[[package]] +name = "ignore" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbe7873dab538a9a44ad79ede1faf5f30d49f9a5c883ddbab48bce81b64b7492" +dependencies = [ + "globset", + "lazy_static", + "log", + "memchr", + "regex", + "same-file", + "thread_local", + "walkdir", + "winapi-util", +] + [[package]] name = "indexmap" version = "2.1.0" @@ -426,12 +521,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" + [[package]] name = "libc" version = "0.2.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c" +[[package]] +name = "libm" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7012b1bbb0719e1097c47611d3898568c546d597c2e74d66f6087edd5233ff4" + [[package]] name = "line-wrap" version = "0.1.1" @@ -513,6 +620,18 @@ dependencies = [ "textwrap", ] +[[package]] +name = "mdbook-tera-backend" +version = "0.0.1" +dependencies = [ + "anyhow", + "mdbook", + "serde", + "serde_json", + "tempdir", + "tera", +] + [[package]] name = "memchr" version = "2.6.4" @@ -585,6 +704,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "parse-zoneinfo" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c705f256449c60da65e11ff6626e0c16a0a0b96aaa348de61376b249bc340f41" +dependencies = [ + "regex", +] + +[[package]] +name = "percent-encoding" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2a4787296e9989611394c33f193f676704af1686e70b8f8033ab5ba9a35a94" + [[package]] name = "pest" version = "2.7.5" @@ -630,6 +764,44 @@ dependencies = [ "sha2", ] +[[package]] +name = "phf" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ade2d8b8f33c7333b51bcf0428d37e217e9f32192ae4772156f65063b8ce03dc" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8d39688d359e6b34654d328e262234662d16cc0f60ec8dcbe5e718709342a5a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e4cc64c2ad9ebe670cb8fd69dd50ae301650392e81c05f9bfcb2d5bdbc24b0" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fcb95eef784c2ac79119d1dd819e162b5da872ce6f3c3abe1e8ca1c082f72b" +dependencies = [ + "siphasher", +] + [[package]] name = "pkg-config" version = "0.3.27" @@ -665,6 +837,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + [[package]] name = "pretty_assertions" version = "1.4.0" @@ -722,6 +900,73 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "552840b97013b1a26992c11eac34bdd778e464601a4c2054b5f0bff7c6761293" +dependencies = [ + "fuchsia-cprng", + "libc", + "rand_core 0.3.1", + "rdrand", + "winapi", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a6fdeb83b075e8266dcc8762c22776f6877a63111121f5f8c7411e5be7eed4b" +dependencies = [ + "rand_core 0.4.2", +] + +[[package]] +name = "rand_core" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c33a3c44ca05fa6f1807d8e6743f3824e8509beca625669633be0acbdf509dc" + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rdrand" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678054eb77286b51581ba43620cc911abf02758c91f93f479767aed0f90458b2" +dependencies = [ + "rand_core 0.3.1", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -766,6 +1011,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi", +] + [[package]] name = "rustix" version = "0.38.21" @@ -854,6 +1108,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7cee0529a6d40f580e7a5e6c495c8fbfe21b7b52795ed4bb5e62cdf92bc6380" +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "slug" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3bc762e6a4b6c6fcaade73e77f9ebc6991b676f88bb2358bddb56560f073373" +dependencies = [ + "deunicode", +] + [[package]] name = "strsim" version = "0.10.0" @@ -892,6 +1161,16 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "tempdir" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f2b5fb00ccdf689e0149d1b1b3c03fead81c2b37735d812fa8bddbbf41b6d8" +dependencies = [ + "rand 0.4.6", + "remove_dir_all", +] + [[package]] name = "tempfile" version = "3.8.1" @@ -905,6 +1184,28 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "tera" +version = "1.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "970dff17c11e884a4a09bc76e3a17ef71e01bb13447a11e85226e254fe6d10b8" +dependencies = [ + "chrono", + "chrono-tz", + "globwalk", + "humansize", + "lazy_static", + "percent-encoding", + "pest", + "pest_derive", + "rand 0.8.5", + "regex", + "serde", + "serde_json", + "slug", + "unic-segment", +] + [[package]] name = "termcolor" version = "1.3.0" @@ -950,6 +1251,16 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdd6f064ccff2d6567adcb3873ca630700f00b5ad3f060c25b5dcfd9a4ce152" +dependencies = [ + "cfg-if", + "once_cell", +] + [[package]] name = "time" version = "0.3.30" @@ -1006,6 +1317,56 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed646292ffc8188ef8ea4d1e0e0150fb15a5c2e12ad9b8fc191ae7a8a7f3c4b9" +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4ed5d26be57f84f176157270c112ef57b86debac9cd21daaabbe56db0f88f23" +dependencies = [ + "unic-ucd-segment", +] + +[[package]] +name = "unic-ucd-segment" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2079c122a62205b421f499da10f3ee0f7697f012f55b675e002483c73ea34700" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicase" version = "2.7.0" @@ -1043,6 +1404,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + [[package]] name = "wasm-bindgen" version = "0.2.88" diff --git a/Cargo.toml b/Cargo.toml index 2cbe95fd..caa9b4c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,4 @@ [workspace] -members = ["i18n-helpers"] +members = ["i18n-helpers", "mdbook-tera-backend"] default-members = ["i18n-helpers"] resolver = "2" diff --git a/README.md b/README.md index e2097f46..8e763bea 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,10 @@ This repository contains the following crates that provide extensions and infrastructure for [mdbook](https://github.com/rust-lang/mdBook/): -- [mdbook-i18n-helpers](i18n-helpers/README.md): Gettext translation support for - [mdbook](https://github.com/rust-lang/mdBook/) +- [mdbook-i18n-helpers](./i18n-helpers/README.md): Gettext translation support + for [mdbook](https://github.com/rust-lang/mdBook/) +- [mdbook-tera-backend](./mdbook-tera-backend/README.md): Tera templates + extension for [mdbook](https://github.com/rust-lang/mdBook/)'s HTML renderer. ## Showcases @@ -39,11 +41,17 @@ cargo install mdbook-i18n-helpers Please see [USAGE](i18n-helpers/USAGE.md) for how to translate your [mdbook](https://github.com/rust-lang/mdBook/) project. -## Changelog - Please see the [i18n-helpers/CHANGELOG](CHANGELOG) for details on the changes in each release. +### `mdbook-tera-backend` + +Run + +```shell +$ cargo install mdbook-tera-backend +``` + ## Contact For questions or comments, please contact diff --git a/i18n-helpers/src/directives.rs b/i18n-helpers/src/directives.rs new file mode 100644 index 00000000..3f718dd6 --- /dev/null +++ b/i18n-helpers/src/directives.rs @@ -0,0 +1,104 @@ +use regex::Regex; +use std::sync::OnceLock; + +#[derive(Debug, PartialEq)] +pub enum Directive { + Skip, + TranslatorComment(String), +} + +pub fn find(html: &str) -> Option { + static RE: OnceLock = OnceLock::new(); + let re = RE.get_or_init(|| { + let pattern = r"(?x) + .*[^-]) # the command part of the prefix + -{2,}> # the closing of the comment + "; + Regex::new(pattern).expect("well-formed regex") + }); + + let captures = re.captures(html.trim())?; + + let command = captures["command"].trim(); + match command.split(is_delimiter).next() { + Some("skip") => Some(Directive::Skip), + Some("comment") => { + let start_of_comment_offset = std::cmp::min( + command.find("comment").unwrap() + "comment".len() + 1, + command.len(), + ); + Some(Directive::TranslatorComment( + command[start_of_comment_offset..].trim().into(), + )) + } + _ => None, + } +} + +fn is_delimiter(c: char) -> bool { + c.is_whitespace() || c == ':' || c == '-' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_comment_skip_directive_simple() { + assert!(matches!(find(""), Some(Directive::Skip))); + } + + #[test] + fn test_is_comment_skip_directive_tolerates_spaces() { + assert!(matches!(find(""), Some(Directive::Skip))); + } + + #[test] + fn test_is_comment_skip_directive_tolerates_dashes() { + assert!(matches!( + find(""), + Some(Directive::Skip) + )); + } + + #[test] + fn test_is_comment_skip_directive_needs_skip() { + assert!(find("").is_none()); + } + + #[test] + fn test_is_comment_skip_directive_needs_to_be_a_comment() { + assert!(find("
i18: skip
").is_none()); + } + + #[test] + fn test_different_prefix() { + assert!(matches!( + find(""), + Some(Directive::Skip) + )); + } + + #[test] + fn test_translator_comment() { + assert!(match find("") { + Some(Directive::TranslatorComment(s)) => { + s == "hello world!" + } + _ => false, + }); + } + + #[test] + fn test_translator_empty_comment_does_nothing() { + assert!(match find("") { + Some(Directive::TranslatorComment(s)) => { + s.is_empty() + } + _ => false, + }); + } +} diff --git a/i18n-helpers/src/lib.rs b/i18n-helpers/src/lib.rs index b8797d99..97f0a533 100644 --- a/i18n-helpers/src/lib.rs +++ b/i18n-helpers/src/lib.rs @@ -26,11 +26,11 @@ use polib::catalog::Catalog; use pulldown_cmark::{CodeBlockKind, Event, LinkType, Tag}; use pulldown_cmark_to_cmark::{cmark_resume_with_options, Options, State}; -use regex::Regex; use std::sync::OnceLock; use syntect::easy::ScopeRangeIterator; use syntect::parsing::{ParseState, Scope, ScopeStack, SyntaxSet}; +pub mod directives; pub mod gettext; pub mod normalize; @@ -287,22 +287,34 @@ pub fn group_events<'a>(events: &'a [(usize, Event<'a>)]) -> Vec> { } } - // An HTML comment directive to skip the next translation - // group. - Event::Html(s) if is_comment_skip_directive(s) => { - // If in the middle of translation, finish it. - if let State::Translate(_) = state { - let mut next_groups; - (next_groups, ctx) = state.into_groups(idx, events, ctx); - groups.append(&mut next_groups); - - // Restart translation: subtle but should be - // needed to handle the skipping of the rest of - // the inlined content. - state = State::Translate(idx); + Event::Html(s) => { + match directives::find(s) { + Some(directives::Directive::Skip) => { + // If in the middle of translation, finish it. + if let State::Translate(_) = state { + let mut next_groups; + (next_groups, ctx) = state.into_groups(idx, events, ctx); + groups.append(&mut next_groups); + + // Restart translation: subtle but should be + // needed to handle the skipping of the rest of + // the inlined content. + state = State::Translate(idx); + } + + ctx.skip_next_group = true; + } + // Otherwise, treat as a skipping group. + _ => { + if let State::Translate(_) = state { + let mut next_groups; + (next_groups, ctx) = state.into_groups(idx, events, ctx); + groups.append(&mut next_groups); + + state = State::Skip(idx); + } + } } - - ctx.skip_next_group = true; } // All other block-level events start or continue a @@ -327,15 +339,6 @@ pub fn group_events<'a>(events: &'a [(usize, Event<'a>)]) -> Vec> { groups } -/// Check whether the HTML is a directive to skip the next translation group. -fn is_comment_skip_directive(html: &str) -> bool { - static RE: OnceLock = OnceLock::new(); - - let re = - RE.get_or_init(|| Regex::new(r"").unwrap()); - re.is_match(html.trim()) -} - /// Returns true if the events appear to be a codeblock. fn is_codeblock_group(events: &[(usize, Event)]) -> bool { matches!( @@ -1294,45 +1297,7 @@ $$ } #[test] - fn test_is_comment_skip_directive_simple() { - assert_eq!( - is_comment_skip_directive(""), - true - ); - } - #[test] - fn test_is_comment_skip_directive_tolerates_spaces() { - assert_eq!( - is_comment_skip_directive(""), - true - ); - } - - #[test] - fn test_is_comment_skip_directive_tolerates_dashes() { - assert_eq!( - is_comment_skip_directive(""), - true - ); - } - - #[test] - fn test_is_comment_skip_directive_needs_skip() { - assert_eq!( - is_comment_skip_directive(""), - false - ); - } - #[test] - fn test_is_comment_skip_directive_needs_to_be_a_comment() { - assert_eq!( - is_comment_skip_directive("
mdbook-xgettext: skip
"), - false - ); - } - - #[test] fn extract_messages_skip_simple() { assert_extract_messages( r#" diff --git a/mdbook-tera-backend/Cargo.toml b/mdbook-tera-backend/Cargo.toml new file mode 100644 index 00000000..a388a1c0 --- /dev/null +++ b/mdbook-tera-backend/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "mdbook-tera-backend" +version = "0.0.1" +authors = ["Martin Geisler ", "Alexandre Senges "] +categories = ["template-engine"] +edition = "2021" +keywords = ["mdbook", "tera", "renderer", "template"] +license = "Apache-2.0" +repository = "https://github.com/google/mdbook-i18n-helpers" +description = "Plugin to extend mdbook with Tera templates and custom HTML components." + +[dependencies] +anyhow = "1.0.75" +mdbook = { version = "0.4.25", default-features = false } +serde = "1.0" +serde_json = "1.0.91" +tera = "1.19.1" + +[dev-dependencies] +tempdir = "0.3.7" diff --git a/mdbook-tera-backend/README.md b/mdbook-tera-backend/README.md new file mode 100644 index 00000000..41535f89 --- /dev/null +++ b/mdbook-tera-backend/README.md @@ -0,0 +1,80 @@ +# Tera backend extension for `mdbook` + +[![Visit crates.io](https://img.shields.io/crates/v/mdbook-i18n-helpers?style=flat-square)](https://crates.io/crates/mdbook-tera-backend) +[![Build workflow](https://img.shields.io/github/actions/workflow/status/google/mdbook-i18n-helpers/test.yml?style=flat-square)](https://github.com/google/mdbook-i18n-helpers/actions/workflows/test.yml?query=branch%3Amain) +[![GitHub contributors](https://img.shields.io/github/contributors/google/mdbook-i18n-helpers?style=flat-square)](https://github.com/google/mdbook-i18n-helpers/graphs/contributors) +[![GitHub stars](https://img.shields.io/github/stars/google/mdbook-i18n-helpers?style=flat-square)](https://github.com/google/mdbook-i18n-helpers/stargazers) + +This `mdbook` backend makes it possible to use +[tera](https://github.com/Keats/tera) templates and expand the capabilities of +your books. It works on top of the default HTML backend. + +## Installation + +Run + +```shell +$ cargo install mdbook-tera-backend +``` + +## Usage + +### Configuring the backend + +To enable the backend, simply add `[output.tera-backend]` to your `book.toml`, +and configure the place where youre templates will live. For instance +`theme/templates`: + +```toml +[output.html] # You must still enable the html backend. +[output.tera-backend] +template_dir = "theme/templates" +``` + +### Creating templates + +Create your template files in the same directory as your book. + +```html + +
+ Hello world! +
+``` + +### Using templates in `index.hbs` + +Since the HTML renderer will first render Handlebars templates, we need to tell +it to ignore Tera templates using `{{{{raw}}}}` blocks: + +```html +{{{{raw}}}} +{% set current_language = ctx.config.book.language %} +

Current language: {{ current_language }}

+{% include "hello_world.html" %} +{{{{/raw}}}} +``` + +Includes names are based on the file name and not the whole file path. + +### Tera documentation + +Find out all you can do with Tera templates +[here](https://keats.github.io/tera/docs/). + +## Changelog + +Please see [CHANGELOG](../CHANGELOG.md) for details on the changes in each +release. + +## Contact + +For questions or comments, please contact +[Martin Geisler](mailto:mgeisler@google.com) or +[Alexandre Senges](mailto:asenges@google.come) or start a +[discussion](https://github.com/google/mdbook-i18n-helpers/discussions). We +would love to hear from you. + +--- + +This is not an officially supported Google product. diff --git a/mdbook-tera-backend/src/main.rs b/mdbook-tera-backend/src/main.rs new file mode 100644 index 00000000..6ef65522 --- /dev/null +++ b/mdbook-tera-backend/src/main.rs @@ -0,0 +1,35 @@ +mod tera_renderer; + +use anyhow::{anyhow, Context}; +use mdbook::renderer::RenderContext; +use std::io; + +use crate::tera_renderer::custom_component::TeraRendererConfig; +use crate::tera_renderer::renderer::Renderer; + +/// Re-renders HTML files outputed by the HTML backend with Tera templates. +/// Please make sure the HTML backend is enabled. +fn main() -> anyhow::Result<()> { + let mut stdin = io::stdin(); + let ctx = RenderContext::from_json(&mut stdin).unwrap(); + if ctx.config.get_renderer("html").is_none() { + return Err(anyhow!( + "Could not find the HTML backend. Please make sure the HTML backend is enabled." + )); + } + let config: TeraRendererConfig = ctx + .config + .get_deserialized_opt("output.tera-backend") + .context("Failed to get tera-backend config")? + .context("No tera-backend config found")?; + + let tera_template = config + .create_template(&ctx.root) + .context("Failed to create components")?; + + let mut renderer = Renderer::new(ctx, tera_template); + + renderer.render_book().context("Failed to render book")?; + + Ok(()) +} diff --git a/mdbook-tera-backend/src/tera_renderer.rs b/mdbook-tera-backend/src/tera_renderer.rs new file mode 100644 index 00000000..9f720662 --- /dev/null +++ b/mdbook-tera-backend/src/tera_renderer.rs @@ -0,0 +1,2 @@ +pub mod custom_component; +pub mod renderer; diff --git a/mdbook-tera-backend/src/tera_renderer/custom_component.rs b/mdbook-tera-backend/src/tera_renderer/custom_component.rs new file mode 100644 index 00000000..46c87e9a --- /dev/null +++ b/mdbook-tera-backend/src/tera_renderer/custom_component.rs @@ -0,0 +1,37 @@ +use anyhow::Result; +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use tera::Tera; + +/// Configuration in `book.toml` `[output.tera-renderer]`. +#[derive(Deserialize)] +pub struct TeraRendererConfig { + /// Relative path to the templates directory from the `book.toml` directory. + pub template_dir: Option, +} + +/// Recursively add all templates in the `template_dir` to the `tera_template`. +fn add_templates_recursively(tera_template: &mut Tera, directory: &Path) -> Result<()> { + for entry in std::fs::read_dir(directory)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + add_templates_recursively(tera_template, &path)?; + } else { + tera_template.add_template_file(&path, path.file_name().unwrap().to_str())?; + } + } + Ok(()) +} + +impl TeraRendererConfig { + /// Create the `tera_template` and add all templates in the `template_dir` to it. + pub fn create_template(&self, current_dir: &Path) -> Result { + let mut tera_template = Tera::default(); + if let Some(template_dir) = &self.template_dir { + add_templates_recursively(&mut tera_template, ¤t_dir.join(template_dir))?; + } + + Ok(tera_template) + } +} diff --git a/mdbook-tera-backend/src/tera_renderer/renderer.rs b/mdbook-tera-backend/src/tera_renderer/renderer.rs new file mode 100644 index 00000000..bfe45833 --- /dev/null +++ b/mdbook-tera-backend/src/tera_renderer/renderer.rs @@ -0,0 +1,174 @@ +use anyhow::{anyhow, Result}; +use mdbook::renderer::RenderContext; +use std::path::Path; +use tera::Tera; + +/// Renderer for the tera backend. +/// +/// This will read all the files in the `RenderContext` and render them using the `Tera` template. +/// ``` +pub struct Renderer { + ctx: RenderContext, + tera_template: Tera, +} + +impl Renderer { + /// Create a new `Renderer` from the `RenderContext` and `Tera` template. + pub fn new(ctx: RenderContext, tera_template: Tera) -> Self { + Renderer { ctx, tera_template } + } + + /// Render the book. This goes through the output of the HTML renderer + /// by considering all the output HTML files as input to the Tera template. + /// It overwrites the preexisting files with their Tera-rendered version. + pub fn render_book(&mut self) -> Result<()> { + let dest_dir = self.ctx.destination.parent().unwrap().join("html"); + if !dest_dir.is_dir() { + return Err(anyhow!( + "{dest_dir:?} is not a directory. Please make sure the HTML renderer is enabled." + )); + } + self.render_book_directory(&dest_dir) + } + + /// Render the book directory located at `path` recursively. + fn render_book_directory(&mut self, path: &Path) -> Result<()> { + for entry in path.read_dir()? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + self.render_book_directory(&path)?; + } else { + self.process_file(&path)?; + } + } + Ok(()) + } + + /// Reads the file at `path` and renders it. + fn process_file(&mut self, path: &Path) -> Result<()> { + if path.extension().unwrap_or_default() != "html" { + return Ok(()); + } + let file_content = std::fs::read_to_string(path)?; + let output = self.render_file_content(&file_content, path)?; + Ok(std::fs::write(path, output)?) + } + + /// Creates the rendering context to be passed to the templates. + /// + /// # Arguments + /// + /// `path`: The path to the file that will be added as extra context to the renderer. + fn create_context(&mut self, path: &Path) -> Result { + let mut context = tera::Context::new(); + let book_dir = self.ctx.destination.parent().unwrap(); + let relative_path = path.strip_prefix(book_dir).unwrap(); + context.insert("path", &relative_path); + context.insert("book_dir", &self.ctx.destination.parent().unwrap()); + + Ok(context) + } + + /// Rendering logic for an individual file. + fn render_file_content(&mut self, file_content: &str, path: &Path) -> Result { + let tera_context = self.create_context(path)?; + + let rendered_file = self + .tera_template + .render_str(file_content, &tera_context) + .map_err(|e| anyhow!("Error rendering file {path:?}: {e:?}"))?; + Ok(rendered_file) + } +} + +#[cfg(test)] +mod test { + use tempdir::TempDir; + + use super::*; + use crate::tera_renderer::custom_component::TeraRendererConfig; + use anyhow::Result; + + const RENDER_CONTEXT_STR: &str = r#" + { + "version":"0.4.32", + "root":"", + "book":{ + "sections": [], + "__non_exhaustive": null + }, + "destination": "", + "config":{ + "book":{ + "authors":[ + "Martin Geisler" + ], + "language":"en", + "multilingual":false, + "src":"src", + "title":"Comprehensive Rust 🦀" + }, + "build":{ + "build-dir":"book", + "use-default-preprocessors":true + }, + "output":{ + "tera-backend": { + "template_dir": "templates" + }, + "renderers":[ + "html", + "tera-backend" + ] + } + } + }"#; + + const HTML_FILE: &str = r#" + + {% include "test_template.html" %} + PATH: {{ path }} + + "#; + + const TEMPLATE_FILE: &str = "RENDERED"; + + const RENDERED_HTML_FILE: &str = r#" + + RENDERED + PATH: html/test.html + + "#; + + #[test] + fn test_renderer() -> Result<()> { + let mut ctx = RenderContext::from_json(RENDER_CONTEXT_STR.as_bytes()).unwrap(); + + let tmp_dir = TempDir::new("output")?; + let html_path = tmp_dir.path().join("html"); + let templates_path = tmp_dir.path().join("templates"); + + std::fs::create_dir(&html_path)?; + std::fs::create_dir(&templates_path)?; + + let html_file_path = html_path.join("test.html"); + std::fs::write(&html_file_path, HTML_FILE)?; + std::fs::write(templates_path.join("test_template.html"), TEMPLATE_FILE)?; + + ctx.destination = tmp_dir.path().join("tera-renderer"); + ctx.root = tmp_dir.path().to_owned(); + + let config: TeraRendererConfig = ctx + .config + .get_deserialized_opt("output.tera-backend")? + .ok_or_else(|| anyhow!("No tera backend configuration."))?; + + let tera_template = config.create_template(&ctx.root)?; + let mut renderer = Renderer::new(ctx, tera_template); + renderer.render_book().expect("Failed to render book"); + + assert_eq!(std::fs::read_to_string(html_file_path)?, RENDERED_HTML_FILE); + Ok(()) + } +}