Skip to content
367 changes: 367 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
[workspace]
members = ["i18n-helpers"]
members = ["i18n-helpers", "mdbook-tera-backend"]
default-members = ["i18n-helpers"]
resolver = "2"
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions mdbook-tera-backend/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "mdbook-tera-backend"
version = "0.0.1"
authors = ["Martin Geisler <[email protected]>", "Alexandre Senges <[email protected]>"]
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"
80 changes: 80 additions & 0 deletions mdbook-tera-backend/README.md
Original file line number Diff line number Diff line change
@@ -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
<!-- ./theme/templates/hello_world.html -->
<div>
Hello world!
</div>
```

### 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 %}
<p>Current language: {{ current_language }}</p>
{% 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:[email protected]) or
[Alexandre Senges](mailto:[email protected]) 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.
35 changes: 35 additions & 0 deletions mdbook-tera-backend/src/main.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
2 changes: 2 additions & 0 deletions mdbook-tera-backend/src/tera_renderer.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
pub mod custom_component;
pub mod renderer;
37 changes: 37 additions & 0 deletions mdbook-tera-backend/src/tera_renderer/custom_component.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf>,
}

/// 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<Tera> {
let mut tera_template = Tera::default();
if let Some(template_dir) = &self.template_dir {
add_templates_recursively(&mut tera_template, &current_dir.join(template_dir))?;
}

Ok(tera_template)
}
}
174 changes: 174 additions & 0 deletions mdbook-tera-backend/src/tera_renderer/renderer.rs
Original file line number Diff line number Diff line change
@@ -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<tera::Context> {
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());
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be computed from ctx so it looks unnecessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's annoying to compute from ctx in tera. I think it's a good addition

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be computed with a small pipeline:

{{ ctx.destination | split(pat="/") | slice(end=-1) | join(sep="/") }}


Ok(context)
}

/// Rendering logic for an individual file.
fn render_file_content(&mut self, file_content: &str, path: &Path) -> Result<String> {
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#"
<!DOCTYPE html>
{% include "test_template.html" %}
PATH: {{ path }}
</html>
"#;

const TEMPLATE_FILE: &str = "RENDERED";

const RENDERED_HTML_FILE: &str = r#"
<!DOCTYPE html>
RENDERED
PATH: html/test.html
</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(())
}
}