Browse Source

Change the template engine for TinyTemplate

As discussed in the issue linked below, the template engine needed to be
changed for various reasons. After a long search, it has been decided to
use TinyTemplate since it is the best match so far.

fixes #8
pull/49/head
Rodolphe Bréard 4 years ago
parent
commit
30be12c79f
  1. 2
      acme_common/Cargo.toml
  2. 4
      acme_common/src/error.rs
  3. 2
      acmed/Cargo.toml
  4. 2
      acmed/build.rs
  5. 48
      acmed/config/default_hooks.toml
  6. 15
      acmed/src/hooks.rs
  7. 1
      acmed/src/main.rs
  8. 5
      acmed/src/storage.rs
  9. 12
      acmed/src/template.rs
  10. 46
      man/en/acmed.toml.5

2
acme_common/Cargo.toml

@ -24,7 +24,6 @@ base64 = "0.13"
daemonize = "0.4" daemonize = "0.4"
env_logger = "0.8" env_logger = "0.8"
glob = "0.3" glob = "0.3"
handlebars = "3.0"
libc = "0.2" libc = "0.2"
log = "0.4" log = "0.4"
native-tls = "0.2" native-tls = "0.2"
@ -33,6 +32,7 @@ openssl-sys = { version = "0.9", optional = true }
punycode = "0.4" punycode = "0.4"
serde_json = "1.0" serde_json = "1.0"
syslog = "5.0" syslog = "5.0"
tinytemplate = "1.2"
toml = "0.5" toml = "0.5"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]

4
acme_common/src/error.rs

@ -105,8 +105,8 @@ impl From<glob::PatternError> for Error {
} }
} }
impl From<handlebars::TemplateRenderError> for Error {
fn from(error: handlebars::TemplateRenderError) -> Self {
impl From<tinytemplate::error::Error> for Error {
fn from(error: tinytemplate::error::Error) -> Self {
format!("template error: {}", error).into() format!("template error: {}", error).into()
} }
} }

2
acmed/Cargo.toml

@ -25,11 +25,11 @@ attohttpc = { version = "0.17", default-features = false, features = ["charsets"
bincode = "1.3" bincode = "1.3"
clap = "2.32" clap = "2.32"
glob = "0.3" glob = "0.3"
handlebars = "3.0"
log = "0.4" log = "0.4"
nom = { version = "6.0", default-features = false, features = [] } nom = { version = "6.0", default-features = false, features = [] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
tinytemplate = "1.2"
toml = "0.5" toml = "0.5"
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]

2
acmed/build.rs

@ -117,7 +117,7 @@ fn set_default_values() {
set_data_path_if_absent!("ACMED_DEFAULT_CERT_DIR", "certs"); set_data_path_if_absent!("ACMED_DEFAULT_CERT_DIR", "certs");
set_env_var_if_absent!( set_env_var_if_absent!(
"ACMED_DEFAULT_CERT_FORMAT", "ACMED_DEFAULT_CERT_FORMAT",
"{{name}}_{{key_type}}.{{file_type}}.{{ext}}"
"{ name }_{ key_type }.{ file_type }.{ ext }"
); );
set_cfg_path_if_absent!("ACMED_DEFAULT_CONFIG_FILE", "acmed.toml"); set_cfg_path_if_absent!("ACMED_DEFAULT_CONFIG_FILE", "acmed.toml");
set_runstate_path_if_absent!("ACMED_DEFAULT_PID_FILE", "acmed.pid"); set_runstate_path_if_absent!("ACMED_DEFAULT_PID_FILE", "acmed.pid");

48
acmed/config/default_hooks.toml

@ -12,7 +12,7 @@
# #
# http-01 challenge in "/var/www/{{identifier}}/"
# http-01 challenge in "/var/www/{ identifier }/"
# #
[[hook]] [[hook]]
@ -21,7 +21,7 @@ type = ["challenge-http-01"]
cmd = "mkdir" cmd = "mkdir"
args = [ args = [
"-m", "0755", "-m", "0755",
"-p", "{{#if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{identifier}}/.well-known/acme-challenge"
"-p", "{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge"
] ]
allow_failure = true allow_failure = true
@ -29,8 +29,8 @@ allow_failure = true
name = "http-01-echo-echo" name = "http-01-echo-echo"
type = ["challenge-http-01"] type = ["challenge-http-01"]
cmd = "echo" cmd = "echo"
args = ["{{proof}}"]
stdout = "{{#if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{identifier}}/.well-known/acme-challenge/{{file_name}}"
args = ["{ proof }"]
stdout = "{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
[[hook]] [[hook]]
name = "http-01-echo-chmod" name = "http-01-echo-chmod"
@ -38,7 +38,7 @@ type = ["challenge-http-01"]
cmd = "chmod" cmd = "chmod"
args = [ args = [
"a+r", "a+r",
"{{#if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{identifier}}/.well-known/acme-challenge/{{file_name}}"
"{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
] ]
allow_failure = true allow_failure = true
@ -48,7 +48,7 @@ type = ["challenge-http-01-clean"]
cmd = "rm" cmd = "rm"
args = [ args = [
"-f", "-f",
"{{#if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{identifier}}/.well-known/acme-challenge/{{file_name}}"
"{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
] ]
allow_failure = true allow_failure = true
@ -71,10 +71,10 @@ name = "tls-alpn-01-tacd-start-tcp"
type = ["challenge-tls-alpn-01"] type = ["challenge-tls-alpn-01"]
cmd = "tacd" cmd = "tacd"
args = [ args = [
"--pid-file", "{{#if env.TACD_PID_ROOT}}{{env.TACD_PID_ROOT}}{{else}}/run{{/if}}/tacd_{{identifier}}.pid",
"--domain", "{{identifier_tls_alpn}}",
"--acme-ext", "{{proof}}",
"--listen", "{{#if env.TACD_HOST}}{{env.TACD_HOST}}{{else}}{{identifier}}{{/if}}:{{#if env.TACD_PORT}}{{env.TACD_PORT}}{{else}}5001{{/if}}"
"--pid-file", "{{ if env.TACD_PID_ROOT }}{ env.TACD_PID_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.pid",
"--domain", "{ identifier_tls_alpn }",
"--acme-ext", "{ proof }",
"--listen", "{{ if env.TACD_HOST }}{ env.TACD_HOST }{{ else }}{ identifier }{{ endif }}:{{ if env.TACD_PORT }}{ env.TACD_PORT }{{ else }}5001{{ endif }}"
] ]
[[hook]] [[hook]]
@ -82,10 +82,10 @@ name = "tls-alpn-01-tacd-start-unix"
type = ["challenge-tls-alpn-01"] type = ["challenge-tls-alpn-01"]
cmd = "tacd" cmd = "tacd"
args = [ args = [
"--pid-file", "{{#if env.TACD_PID_ROOT}}{{env.TACD_PID_ROOT}}{{else}}/run{{/if}}/tacd_{{identifier}}.pid",
"--domain", "{{identifier_tls_alpn}}",
"--acme-ext", "{{proof}}",
"--listen", "unix:{{#if env.TACD_SOCK_ROOT}}{{env.TACD_SOCK_ROOT}}{{else}}/run{{/if}}/tacd_{{identifier}}.sock"
"--pid-file", "{{ if env.TACD_PID_ROOT }}{ env.TACD_PID_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.pid",
"--domain", "{ identifier_tls_alpn }",
"--acme-ext", "{ proof }",
"--listen", "unix:{{ if env.TACD_SOCK_ROOT }}{ env.TACD_SOCK_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.sock"
] ]
[[hook]] [[hook]]
@ -93,7 +93,7 @@ name = "tls-alpn-01-tacd-kill"
type = ["challenge-tls-alpn-01-clean"] type = ["challenge-tls-alpn-01-clean"]
cmd = "pkill" cmd = "pkill"
args = [ args = [
"-F", "{{#if env.TACD_PID_ROOT}}{{env.TACD_PID_ROOT}}{{else}}/run{{/if}}/tacd_{{identifier}}.pid",
"-F", "{{ if env.TACD_PID_ROOT }}{ env.TACD_PID_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.pid",
] ]
allow_failure = true allow_failure = true
@ -102,7 +102,7 @@ name = "tls-alpn-01-tacd-rm"
type = ["challenge-tls-alpn-01-clean"] type = ["challenge-tls-alpn-01-clean"]
cmd = "rm" cmd = "rm"
args = [ args = [
"-f", "{{#if env.TACD_PID_ROOT}}{{env.TACD_PID_ROOT}}{{else}}/run{{/if}}/tacd_{{identifier}}.pid",
"-f", "{{ if env.TACD_PID_ROOT }}{ env.TACD_PID_ROOT }{{ else }}/run{{ endif }}/tacd_{ identifier }.pid",
] ]
allow_failure = true allow_failure = true
@ -125,7 +125,7 @@ type = ["file-pre-create", "file-pre-edit"]
cmd = "git" cmd = "git"
args = [ args = [
"init", "init",
"{{file_directory}}"
"{ file_directory }"
] ]
[[hook]] [[hook]]
@ -133,8 +133,8 @@ name = "git-add"
type = ["file-post-create", "file-post-edit"] type = ["file-post-create", "file-post-edit"]
cmd = "git" cmd = "git"
args = [ args = [
"-C", "{{file_directory}}",
"add", "{{file_name}}"
"-C", "{ file_directory }",
"add", "{ file_name }"
] ]
allow_failure = true allow_failure = true
@ -143,12 +143,12 @@ name = "git-commit"
type = ["file-post-create", "file-post-edit"] type = ["file-post-create", "file-post-edit"]
cmd = "git" cmd = "git"
args = [ args = [
"-C", "{{file_directory}}",
"-c", "user.name='{{#if env.GIT_USERNAME}}{{env.GIT_USERNAME}}{{else}}ACMEd{{/if}}'",
"-c", "user.email='{{#if env.GIT_EMAIL}}{{env.GIT_EMAIL}}{{else}}acmed@localhost{{/if}}'",
"-C", "{ file_directory }",
"-c", "user.name='{{ if env.GIT_USERNAME }}{ env.GIT_USERNAME }{{ else }}ACMEd{{ endif }}'",
"-c", "user.email='{{ if env.GIT_EMAIL }}{ env.GIT_EMAIL }{{ else }}acmed@localhost{{ endif }}'",
"commit", "commit",
"-m", "{{file_name}}",
"--only", "{{file_name}}"
"-m", "{ file_name }",
"--only", "{ file_name }"
] ]
allow_failure = true allow_failure = true

15
acmed/src/hooks.rs

@ -1,7 +1,7 @@
pub use crate::config::HookType; pub use crate::config::HookType;
use crate::logs::HasLogger; use crate::logs::HasLogger;
use crate::template::render_template;
use acme_common::error::Error; use acme_common::error::Error;
use handlebars::Handlebars;
use serde::Serialize; use serde::Serialize;
use std::collections::hash_map::Iter; use std::collections::hash_map::Iter;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -102,10 +102,10 @@ impl fmt::Display for Hook {
} }
macro_rules! get_hook_output { macro_rules! get_hook_output {
($logger: expr, $out: expr, $reg: ident, $data: expr, $hook_name: expr, $out_name: expr) => {{
($logger: expr, $out: expr, $data: expr, $hook_name: expr, $out_name: expr) => {{
match $out { match $out {
Some(path) => { Some(path) => {
let path = $reg.render_template(path, $data)?;
let path = render_template(path, $data)?;
$logger.trace(&format!( $logger.trace(&format!(
"hook \"{}\": {}: {}", "hook \"{}\": {}: {}",
$hook_name, $out_name, &path $hook_name, $out_name, &path
@ -124,12 +124,11 @@ where
T: Clone + HookEnvData + Serialize, T: Clone + HookEnvData + Serialize,
{ {
logger.debug(&format!("calling hook \"{}\"", hook.name)); logger.debug(&format!("calling hook \"{}\"", hook.name));
let reg = Handlebars::new();
let mut v = vec![]; let mut v = vec![];
let args = match &hook.args { let args = match &hook.args {
Some(lst) => { Some(lst) => {
for fmt in lst.iter() { for fmt in lst.iter() {
let s = reg.render_template(fmt, &data)?;
let s = render_template(fmt, &data)?;
v.push(s); v.push(s);
} }
v.as_slice() v.as_slice()
@ -144,7 +143,6 @@ where
.stdout(get_hook_output!( .stdout(get_hook_output!(
logger, logger,
&hook.stdout, &hook.stdout,
reg,
&data, &data,
&hook.name, &hook.name,
"stdout" "stdout"
@ -152,7 +150,6 @@ where
.stderr(get_hook_output!( .stderr(get_hook_output!(
logger, logger,
&hook.stderr, &hook.stderr,
reg,
&data, &data,
&hook.name, &hook.name,
"stderr" "stderr"
@ -164,7 +161,7 @@ where
.spawn()?; .spawn()?;
match &hook.stdin { match &hook.stdin {
HookStdin::Str(s) => { HookStdin::Str(s) => {
let data_in = reg.render_template(&s, &data)?;
let data_in = render_template(&s, &data)?;
logger.trace(&format!( logger.trace(&format!(
"hook \"{}\": string stdin: {}", "hook \"{}\": string stdin: {}",
hook.name, &data_in hook.name, &data_in
@ -173,7 +170,7 @@ where
stdin.write_all(data_in.as_bytes())?; stdin.write_all(data_in.as_bytes())?;
} }
HookStdin::File(f) => { HookStdin::File(f) => {
let file_name = reg.render_template(&f, &data)?;
let file_name = render_template(&f, &data)?;
logger.trace(&format!( logger.trace(&format!(
"hook \"{}\": file stdin: {}", "hook \"{}\": file stdin: {}",
hook.name, &file_name hook.name, &file_name

1
acmed/src/main.rs

@ -20,6 +20,7 @@ mod jws;
mod logs; mod logs;
mod main_event_loop; mod main_event_loop;
mod storage; mod storage;
mod template;
pub const APP_NAME: &str = "ACMEd"; pub const APP_NAME: &str = "ACMEd";
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");

5
acmed/src/storage.rs

@ -1,9 +1,9 @@
use crate::hooks::{self, FileStorageHookData, Hook, HookEnvData, HookType}; use crate::hooks::{self, FileStorageHookData, Hook, HookEnvData, HookType};
use crate::logs::HasLogger; use crate::logs::HasLogger;
use crate::template::render_template;
use acme_common::b64_encode; use acme_common::b64_encode;
use acme_common::crypto::{KeyPair, X509Certificate}; use acme_common::crypto::{KeyPair, X509Certificate};
use acme_common::error::Error; use acme_common::error::Error;
use handlebars::Handlebars;
use serde::Serialize; use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::fmt; use std::fmt;
@ -110,8 +110,7 @@ fn get_file_full_path(
file_type: file_type.to_string(), file_type: file_type.to_string(),
name: fm.crt_name.to_owned(), name: fm.crt_name.to_owned(),
}; };
let reg = Handlebars::new();
reg.render_template(&fm.crt_name_format, &fmt_data)?
render_template(&fm.crt_name_format, &fmt_data)?
} }
}; };
let mut path = PathBuf::from(&base_path); let mut path = PathBuf::from(&base_path);

12
acmed/src/template.rs

@ -0,0 +1,12 @@
use acme_common::error::Error;
use serde::Serialize;
use tinytemplate::TinyTemplate;
pub fn render_template<T>(template: &str, data: &T) -> Result<String, Error>
where
T: Serialize,
{
let mut reg = TinyTemplate::new();
reg.add_template("reg", template)?;
Ok(reg.render("reg", data)?)
}

46
man/en/acmed.toml.5

@ -118,17 +118,17 @@ Name of the endpoint to use.
Table of environment variables that will be accessible from hooks. Table of environment variables that will be accessible from hooks.
.It Ic file_name_format Ar string .It Ic file_name_format Ar string
Template used to build the file's name. The template syntax is Template used to build the file's name. The template syntax is
.Em Handlebars .
.Em TinyTemplate .
See the See the
.Sx STANDARDS .Sx STANDARDS
section for a link to the section for a link to the
.Em Handlebars
.Em TinyTemplate
specifications. If not specified, the value defined in the specifications. If not specified, the value defined in the
.Em endpoint .Em endpoint
element, and then the element, and then the
.Em global .Em global
element, is used. Default is element, is used. Default is
.Dq {{name}}_{{key_type}}.{{file_type}}.{{ext}} .
.Dq { name }_{ key_type }.{ file_type }.{ ext } .
Possible variables are: Possible variables are:
.Bl -tag .Bl -tag
.It Ic ext Ar string .It Ic ext Ar string
@ -401,11 +401,11 @@ and
are considered as template strings whereas are considered as template strings whereas
.Em cmd .Em cmd
is not. The template syntax is is not. The template syntax is
.Em Handlebars .
.Em TinyTemplate .
See the See the
.Sx STANDARDS .Sx STANDARDS
section for a link to the section for a link to the
.Em Handlebars
.Em TinyTemplate
specifications. specifications.
.Pp .Pp
The available types and the associated template variable are described below. The available types and the associated template variable are described below.
@ -577,10 +577,10 @@ and
environment variables. environment variables.
.It Pa http-01-echo .It Pa http-01-echo
This hook is designed to solve the http-01 challenge. For this purpose, it will write the proof into This hook is designed to solve the http-01 challenge. For this purpose, it will write the proof into
.Pa {{env.HTTP_ROOT}}/{{identifier}}/.well-known/acme-challenge/{{file_name}} .
.Pa { env.HTTP_ROOT }/{ identifier }/.well-known/acme-challenge/{ file_name } .
.Pp .Pp
The web server must be configured so the file The web server must be configured so the file
.Pa http://{{identifier}}/.well-known/acme-challenge/{{file_name}}
.Pa http://{ identifier }/.well-known/acme-challenge/{ file_name }
can be accessed from the CA. can be accessed from the CA.
.Pp .Pp
If If
@ -605,7 +605,7 @@ environment variable (default is 5001).
.Pp .Pp
.Xr tacd 8 .Xr tacd 8
will store its pid into will store its pid into
.Pa {{TACD_PID_ROOT}}/tacd_{{identifier}}.pid .
.Pa { TACD_PID_ROOT }/tacd_{ identifier }.pid .
If If
.Ev TACD_PID_ROOT .Ev TACD_PID_ROOT
is not specified, it will be set to is not specified, it will be set to
@ -621,7 +621,7 @@ option.
.Pp .Pp
.Xr tacd 8 .Xr tacd 8
will listen on the unix socket will listen on the unix socket
.Pa {{env.TACD_SOCK_ROOT}}/tacd_{{identifier}}.sock .
.Pa { env.TACD_SOCK_ROOT }/tacd_{ identifier }.sock .
If If
.Ev TACD_SOCK_ROOT .Ev TACD_SOCK_ROOT
is not specified, it will be set to is not specified, it will be set to
@ -629,7 +629,7 @@ is not specified, it will be set to
.Pp .Pp
.Xr tacd 8 .Xr tacd 8
will store its pid into will store its pid into
.Pa {{TACD_PID_ROOT}}/tacd_{{identifier}}.pid .
.Pa { TACD_PID_ROOT }/tacd_{ identifier }.pid .
If If
.Ev TACD_PID_ROOT .Ev TACD_PID_ROOT
is not specified, it will be set to is not specified, it will be set to
@ -726,15 +726,15 @@ type = ["challenge-http-01"]
cmd = "mkdir" cmd = "mkdir"
args = [ args = [
"-m", "0755", "-m", "0755",
"-p", "{{%if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{identifier}}/.well-known/acme-challenge"
"-p", "{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge"
] ]
[[hook]] [[hook]]
name = "http-01-echo-echo" name = "http-01-echo-echo"
type = ["challenge-http-01"] type = ["challenge-http-01"]
cmd = "echo" cmd = "echo"
args = ["{{proof}}"]
stdout = "{{%if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{identifier}}/.well-known/acme-challenge/{{file_name}}"
args = ["{ proof }"]
stdout = "{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
[[hook]] [[hook]]
name = "http-01-echo-chmod" name = "http-01-echo-chmod"
@ -742,7 +742,7 @@ type = ["challenge-http-01-clean"]
cmd = "chmod" cmd = "chmod"
args = [ args = [
"a+r", "a+r",
"{{%if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{identifier}}/.well-known/acme-challenge/{{file_name}}"
"{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
] ]
[[hook]] [[hook]]
@ -751,7 +751,7 @@ type = ["challenge-http-01-clean"]
cmd = "rm" cmd = "rm"
args = [ args = [
"-f", "-f",
"{{%if env.HTTP_ROOT}}{{env.HTTP_ROOT}}{{else}}/var/www{{/if}}/{{identifier}}/.well-known/acme-challenge/{{file_name}}"
"{{ if env.HTTP_ROOT }}{ env.HTTP_ROOT }{{ else }}/var/www{{ endif }}/{ identifier }/.well-known/acme-challenge/{ file_name }"
] ]
.Ed .Ed
.Pp .Pp
@ -784,12 +784,12 @@ args = [
"-f", "noreply.certs@example.net", "-f", "noreply.certs@example.net",
"contact@example.net" "contact@example.net"
] ]
stdin_str = """Subject: Certificate renewal {{#if is_success}}succeeded{{else}}failed{{/if}} for {{identifiers.[0]}}
stdin_str = """Subject: Certificate renewal {{ if is_success }}succeeded{{ else }}failed{{ endif }} for { identifiers.0 }
The following certificate has {{#unless is_success}}*not* {{/unless}}been renewed.
identifiers: {{#each identifiers}}{{#if @index}}, {{/if}}{{this}}{{/each}}
key type: {{key_type}}
status: {{status}}"""
The following certificate has {{ if not is_success }}*not* {{ endif }}been renewed.
identifiers: {{ for ident in identifiers}}{{ if @index }}, {{ endif }}{ ident }{{ endfor}}
key type: { key_type }
status: { status }"""
.Ed .Ed
.Sh SEE ALSO .Sh SEE ALSO
.Xr acmed 8 , .Xr acmed 8 ,
@ -805,9 +805,9 @@ status: {{status}}"""
.Re .Re
.It .It
.Rs .Rs
.%A Yehuda Katz
.%T Handlebars
.%U https://handlebarsjs.com/
.%A Brook Heisler
.%T TinyTemplate
.%U https://docs.rs/tinytemplate/latest/tinytemplate/syntax/index.html
.Re .Re
.It .It
.Rs .Rs

Loading…
Cancel
Save