Extending Markups in Zola

October 2, 2021

Solution: Use shortcodes. Write an API server to handle complicated cases.

Zola is a static site generator written in Rust. This website has migrated from Gatsby + Foremark to Zola in 2021 to reduce the maintenance complexity. This posed a few challenges, namely the lack of advanced markup features. Aside from the exception of shortcodes, Zola is mostly compliant to CommonMark, which was severely lacking in terms of features compared to Foremark. Among the missing features that were essential for this website were LaTeX equations, ASCII diagrams, admonitions, and definition lists.

Plugin support is a long-awaited feature for Zola, but there's still a long way to go before a consensus is made on this feature. I could fork Zola and make needed changes, but keeping up with upstream changes is too much of a task to make a habit of. Therefore, I had to make do with the available tools.

Warning

Example code in this document contains hidden characters (U+200B) to prevent the shortcodes inside code blocks from being unintentionally processed.

🔗Shortcodes

Shortcodes are user-defined tags that are rendered to HTML by Tera templates. Simple transformations, such as those required for admonitions, can be expressed by them. E.g.:

{% admonition(type="tip", title="Title") %​}
    **Body** body body
{% end %}

The above renders as:

<nb-admonition type="tip" role="group" aria-labelledby="admonition_1">
    <nb-admonition-title id="admonition_1">
        Title
    </nb-admonition-title>
    <p>
    	<b>Body</b> body body
    </p>
</nb-admonition>

While this handles simple cases very well, the programming capability of Tera is quite limited and precludes doing things like rendering LaTeX equations.

🔗Tera Function: load_data

Zola provides a Tera function load_data, which loads data from an external file or http[s]-scheme URL. As far as I know, this is the only way for a Zola Tera template to invoke an external program for transforming data. So we want to set up a local HTTP server, providing API endpoints that take parameters and respond with the rendered HTML code. The server can be written in any language and framework (e.g., Rust and Humphrey). However, having to run the server separately every time you need to work on your Zola website isn't exactly pretty. (And it won't stick.)

🔗Zola CLI Wrapper

The solution I have found ideal is to write a wrapper program that forwards all arguments to the Zola CLI application while simultaneously running a server in the background. So you would do cargo run serve1 instead of zola serve, and UI-wise everything else would remain the same, but Tera templates would have access to the server. The main function of the wrapper program should look like the following:

fn main() {
    // Start local API server
    let port = start_server();

    // Invoke Zola
    let code = std::process::Command::new("zola")
    	.args(std::env::args_os().skip(1)) // Forward all parameters
    	.env("HELPER_API", format!("http://127.0.0.1:{}", port))
    	.spawn()
        .expect("Could not spawn the command `zola`")
        .wait()
        .expect("Could not wait for the command `zola` to complete")
        .code()
        .expect("Zola received a fatal signal");

    std::process::exit(code);
}

fn start_server() -> u16 {
	// Start an HTTP server in a background thread and return its
	// port number
	todo!()
}

The server port number could be allocated automatically (which isn't supported by Humphrey out-of-box, unfortunately), passed as an environment variable, and retrieved by a Tera template using the get_env built-in Tera function. The Tera template for a shortcode to render a LaTeX equation could look like the following:

{% set url = get_env(name="HELPER_API", default="") %}
{% if url == "" %}
    <code>{{ tex }}</code>
{% else %}
    {{ load_data(url=url ~ "/katex.html",
                 format="plain",
                 method="POST",
                 content_type="text/plain",
                 body=tex) | safe }}
{% endif %}

This template would be instantiated by a shortcode like {​{eq(tex="E = mc^2")}}, which might not be as appealing as $E = mc^2$ but is fully supported by Zola and doesn't get messed up by the CommonMark renderer that doesn't understand this syntax.

1

cargo run invokes the Cargo package manager to build and execute a Rust application, which refers to the wrapper program in this case.