Initial commit

This commit is contained in:
Mikko Ahlroth 2023-02-26 22:48:34 +02:00
commit c2117bc6ff
19 changed files with 1926 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
*.beam
*.ez
build
erl_crash.dump

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
erlang 25.2.2
gleam 0.26.2

4
CHANGELOG Normal file
View file

@ -0,0 +1,4 @@
1.0.0
-----
Initial release.

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2023 Mikko Ahlroth
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

61
README.md Normal file
View file

@ -0,0 +1,61 @@
# glemplate
[![Package Version](https://img.shields.io/hexpm/v/glemplate)](https://hex.pm/packages/glemplate)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/glemplate/)
Glemplate is a simplistic template engine for Gleam. The aim was to learn how to make such a thing
and fill simple use cases. At this stage, it can be used for simple plain text and HTML templates,
and for other contexts by supplying a custom encoding function.
## Quick start
```gleam
import gleam/map
import glemplate/parser
import glemplate/html
import glemplate/assigns
let template = "<b>Welcome, <%= name %>!</b>"
assert Ok(tpl) = parser.parse_to_template(template, "input.html.glemp")
let assigns = assigns.from_list([#("name", assigns.String("<Nicd>"))])
let template_cache = map.new()
html.render(tpl, assigns, template_cache) // "<b>Welcome, &lt;Nicd&gt;!</b>"
```
The main modules:
- [glemplate/parser](glemplate/parser.html): The parser implements and documents the string
template syntax.
- [glemplate/ast](glemplate/ast.html): The abstract syntax tree (AST) is the data format of the
parsed templates.
- [glemplate/renderer](glemplate/renderer.html): The renderer takes in the parsed AST, input data,
and a cache of templates, and renders the template into an output string builder.
- [glemplate/html](glemplate/html.html) and [glemplate/text](glemplate/text.html) have helper
functions for rendering HTML and plain text templates.
Using templates with Glemplate has two main things to deal with:
1. parsing templates into AST with the parser, and
2. rendering that AST into some output with assigns (input data).
Since Gleam has no metaprogramming, the parsing of templates needs to be done at startup time (or
whenever deemed necessary when templates have changed). The resulting AST should be stored for
later use in e.g. ETS, process state, persistent_term…
## Limitations
- Static values in templates in place of variables aren't supported.
- It's possible to create infinite loops with child templates, it's not recommended to give access
to writing templates to untrusted users.
- Calling functions from templates is not supported.
## Installation
This package can be added to your Gleam project:
```sh
gleam add glemplate
```
and its documentation can be found at <https://hexdocs.pm/glemplate>.

18
gleam.toml Normal file
View file

@ -0,0 +1,18 @@
name = "glemplate"
version = "1.0.0"
description = "A Gleam project"
# Fill out these fields if you intend to generate HTML documentation or publish
# your project to the Hex package manager.
#
licences = ["MIT"]
repository = { type = "gitlab", user = "Nicd", repo = "glemplate" }
links = []
[dependencies]
gleam_stdlib = "~> 0.26"
nibble = "~> 0.2"
glentities = "~> 1.0"
[dev-dependencies]
gleeunit = "~> 0.10"

15
manifest.toml Normal file
View file

@ -0,0 +1,15 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "gleam_stdlib", version = "0.26.1", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "B17BBE8A78F3909D93BCC6C24F531673A7E328A61F24222EB1E58D0A7552B1FE" },
{ name = "gleeunit", version = "0.10.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "ECEA2DE4BE6528D36AFE74F42A21CDF99966EC36D7F25DEB34D47DD0F7977BAF" },
{ name = "glentities", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "glentities", source = "hex", outer_checksum = "9F602AB9A9DB0B4905BCDEC05B8586A77C17021210B85034AE3D182BD8024FE2" },
{ name = "nibble", version = "0.2.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "nibble", source = "hex", outer_checksum = "B5D0E11DADEB4EC2F4ACD04DEB3CEBE3F0762B0BD9FF37805948FFC17017F791" },
]
[requirements]
gleam_stdlib = "~> 0.26"
gleeunit = "~> 0.10"
glentities = "~> 1.0"
nibble = "~> 0.2"

View file

@ -0,0 +1,59 @@
//// Assigns are used to insert dynamic data into a template. This module
//// contains convenience functions for creating assigns, but you may also
//// create the map manually with Gleam's map functions if you so wish.
import gleam/map.{Map}
import glemplate/ast.{VarName}
/// Data in an assign. Note that only `String` and `Int` are stringifiable
/// values.
pub type AssignData {
String(String)
Int(Int)
/// A function to execute that returns assign data on demand.
Lazy(LazyFn)
Bool(Bool)
Map(Map(VarName, AssignData))
List(List(AssignData))
}
/// Lazy function that should return assign data when executed.
pub type LazyFn =
fn() -> AssignData
/// Assigns given to a template for rendering dynamic content.
pub type Assigns =
Map(VarName, AssignData)
pub fn new() -> Assigns {
map.new()
}
pub fn from_list(list: List(#(VarName, AssignData))) -> Assigns {
map.from_list(list)
}
pub fn add_string(assigns: Assigns, name: VarName, value: String) {
map.insert(assigns, name, String(value))
}
pub fn add_int(assigns: Assigns, name: VarName, value: Int) {
map.insert(assigns, name, Int(value))
}
pub fn add_lazy(assigns: Assigns, name: VarName, value: LazyFn) {
map.insert(assigns, name, Lazy(value))
}
pub fn add_bool(assigns: Assigns, name: VarName, value: Bool) {
map.insert(assigns, name, Bool(value))
}
pub fn add_list(assigns: Assigns, name: VarName, value: List(AssignData)) {
map.insert(assigns, name, List(value))
}
pub fn add_map(assigns: Assigns, name: VarName, value: Map(VarName, AssignData)) {
map.insert(assigns, name, Map(value))
}

55
src/glemplate/ast.gleam Normal file
View file

@ -0,0 +1,55 @@
//// The abstract syntax tree that templates are stored as in memory. This AST
//// can be rendered with the renderer module.
pub type Node {
Text(String)
Dynamic(DynamicNode)
Nodes(nodes: NodeList)
}
pub type NodeList =
List(Node)
pub type Template {
Template(name: TemplateName, nodes: NodeList)
}
/// A dynamic node is used for dynamic behaviour in a template.
pub type DynamicNode {
/// Output the variable, encoded.
Output(Var)
/// Output the variable, without encoding.
RawOutput(Var)
/// Render a node list based on if the assign is truthy (non-false).
If(Var, if_true: NodeList, if_false: NodeList)
/// Iterate over the variable, bind it to a new name, and render the node list
/// for every item.
Iter(over: Var, binding: VarName, NodeList)
/// Render the given template, using the assigns mapping. The assigns mapping
/// is a list of tuples `(from, to)`, where the `from` named assigns are
/// available with `to` names in the child template.
Render(tpl: TemplateName, assigns_map: List(#(Var, VarName)))
}
/// A reference to a template with the given name.
pub type TemplateName =
String
/// A reference to a variable input.
pub type Var {
/// Reference to an assign with the given name.
Assign(name: VarName)
/// Accessing a field of an associative container.
FieldAccess(container: Var, field: String)
/// Accessing an item in an indexable container.
IndexAccess(container: Var, index: Int)
}
pub type VarName =
String

26
src/glemplate/html.gleam Normal file
View file

@ -0,0 +1,26 @@
//// Utilities for rendering HTML templates.
import gleam/string_builder.{StringBuilder}
import glentities
import glemplate/renderer
import glemplate/ast
import glemplate/assigns
/// Encode content as HTML entities so it's safe in an HTML template.
pub fn encode(text: String) -> StringBuilder {
glentities.encode(text, glentities.Named)
|> string_builder.from_string()
}
/// Render an HTML template.
pub fn render(
template: ast.Template,
assigns: assigns.Assigns,
template_cache: renderer.TemplateCache,
) -> renderer.RenderResult {
renderer.render(
template,
assigns,
renderer.RenderOptions(encoder: encode, template_cache: template_cache),
)
}

486
src/glemplate/parser.gleam Normal file
View file

@ -0,0 +1,486 @@
//// The parser transforms a string input into a template AST.
////
//// ## Template language description
////
//// The template language uses `<%` and `%>` as the tag delimiters. For
//// outputting, the start delimiter `<%=` is used.
////
//// Comments can be inserted with `<%!-- Comment --%>`, they are not emitted to
//// the AST.
////
//// Assigns can be referenced using their name (key in the assigns map). After
//// an assign name, the contents of the assign can be accessed using field or
//// index based access:
////
//// * `foo.bar` accesses field `bar` in assign `foo`.
//// * `foo.0` accesses the first element in assign `foo`.
////
//// The accesses can be chained, e.g. `foo.bar.0.baz`. These access methods
//// work in any place where an assign reference is expected.
////
//// Any other content in the template is emitted as text nodes.
////
//// ### Tags
////
//// #### Output
////
//// ```text
//// <%= foo %>
//// ```
////
//// Emits an output node of the assign `foo`. The contents are encoded using
//// the encoder given in the render call.
////
//// #### Raw output
////
//// ```text
//// <%= raw foo %>
//// ```
////
//// Emits an output node of the assign `foo`. The contents are not encoded.
////
//// #### If
////
//// ```text
//// <% if foo %>
//// ...
//// <% else %>
//// ...
//// <% end %>
//// ```
////
//// Emits an if node. If the assign `foo` is truthy (not `False`), the first
//// nodes are rendered, otherwise the nodes in the `else` block. The `else`
//// block is optional.
////
//// #### For
////
//// ```text
//// <% for bar in foo %>
//// ...
//// <% end %>
//// ```
////
//// Emits a for node. Each item of `foo` is looped through, making it available
//// as the assign `bar` inside the block. The contents of the block are
//// rendered for each item.
////
//// #### Render
////
//// ```text
//// <% render tpl.txt.glemp %>
//// <% render tpl.txt.glemp name: user.name, level: user.level %>
//// ```
////
//// Emits a render node. This will render another template in this place. The
//// listed assigns will be the assigns given to the template. The name of the
//// assign in the child template is on the left, the value is on the right.
import gleam/string_builder
import gleam/list
import gleam/option.{None, Option, Some}
import gleam/result
import gleam/int
import nibble
import nibble/predicates
import glemplate/ast
const tag_start = "<%"
const tag_end = "%>"
const tag_escape_char = "%"
const tag_comment_start = "!--"
const tag_comment_end = "--"
const tag_output = "="
const end_token = "end"
const if_token = "if"
const else_token = "else"
const iter_token = "for"
const iter_oper_token = "in"
const output_raw_token = "raw"
const render_token = "render"
const render_binding_separator = ":"
const render_pair_separator = ","
const access_separator = "."
type IncompleteTag {
If(var: ast.Var, if_true: Option(ast.NodeList))
Iter(over: ast.Var, binding: ast.VarName)
}
type IncompleteAccess {
FieldAccess(field: String)
IndexAccess(index: Int)
}
type State {
State(
emitted: ast.NodeList,
current_text: string_builder.StringBuilder,
current_nodes: ast.NodeList,
current_tag: Option(IncompleteTag),
outer_state: Option(State),
)
}
/// Parse the string into a template with the given name.
pub fn parse_to_template(template: String, name: String) {
try parsed = parse(template)
Ok(ast.Template(name: name, nodes: parsed))
}
/// Parse the string into a node list.
pub fn parse(template: String) {
nibble.run(template, base_parser(empty_state()))
|> result.map(fn(state: State) { state.emitted })
}
fn empty_state() {
State(
emitted: [],
current_nodes: [],
current_text: string_builder.new(),
current_tag: None,
outer_state: None,
)
}
fn base_parser(old_state: State) {
nibble.loop(
old_state,
fn(state) {
nibble.one_of([
tag_parser(state)
|> nibble.map(nibble.Continue),
nibble.any()
|> nibble.map(fn(c) {
State(
..state,
current_text: string_builder.append(state.current_text, c),
)
})
|> nibble.map(nibble.Continue),
nibble.eof()
|> nibble.replace(stringify_current_text(state))
|> nibble.map(nibble.Break),
])
},
)
|> nibble.map(fn(state) {
State(
..state,
emitted: [ast.Nodes(state.emitted), ..list.reverse(state.current_nodes)],
current_nodes: [],
current_text: string_builder.new(),
)
})
}
fn tag_parser(state: State) {
use _ <- nibble.then(nibble.string(tag_start))
let state = stringify_current_text(state)
nibble.one_of([
escaped_tag(state),
comment_tag(state),
output_tag(state),
other_tag(state),
])
}
fn escaped_tag(state: State) {
nibble.string(tag_escape_char)
|> nibble.replace(
State(..state, current_nodes: [ast.Text(tag_start), ..state.current_nodes]),
)
}
fn comment_tag(state: State) {
use _ <- nibble.then(nibble.string(tag_comment_start))
nibble.loop(
Nil,
fn(_) {
nibble.one_of([
nibble.string(tag_comment_end <> tag_end)
|> nibble.map(nibble.Break),
nibble.any()
|> nibble.replace(nibble.Continue(Nil)),
])
},
)
|> nibble.replace(state)
}
fn output_tag(state: State) {
use _ <- nibble.then(nibble.string(tag_output))
nibble.one_of([
{
// Raw output
use _ <- nibble.then(nibble.whitespace())
use _ <- nibble.then(nibble.string(output_raw_token))
use _ <- nibble.then(nibble.whitespace())
use var <- nibble.then(variable())
nibble.succeed(
State(
..state,
current_nodes: [
ast.Dynamic(ast.RawOutput(var)),
..state.current_nodes
],
),
)
},
{
// Normal output
use _ <- nibble.then(nibble.whitespace())
use var <- nibble.then(variable())
nibble.succeed(
State(
..state,
current_nodes: [ast.Dynamic(ast.Output(var)), ..state.current_nodes],
),
)
},
])
|> nibble.drop(nibble.whitespace())
|> nibble.drop(nibble.string(tag_end))
}
fn other_tag(state: State) {
use _ <- nibble.then(nibble.whitespace())
nibble.one_of([
if_tag(state),
for_tag(state),
render_tag(state),
else_tag(state),
end_tag(state),
])
|> nibble.drop(nibble.whitespace())
|> nibble.drop(nibble.string(tag_end))
}
fn if_tag(state: State) {
use _ <- nibble.then(nibble.string(if_token))
use _ <- nibble.then(nibble.whitespace())
use var <- nibble.then(variable())
nibble.succeed(
State(
..empty_state(),
current_tag: Some(If(var, None)),
outer_state: Some(state),
),
)
}
fn for_tag(state: State) {
use _ <- nibble.then(nibble.string(iter_token))
use _ <- nibble.then(nibble.whitespace())
use binding <- nibble.then(variable_name())
use _ <- nibble.then(nibble.whitespace())
use _ <- nibble.then(nibble.string(iter_oper_token))
use _ <- nibble.then(nibble.whitespace())
use over <- nibble.then(variable())
nibble.succeed(
State(
..empty_state(),
current_tag: Some(Iter(over, binding)),
outer_state: Some(state),
),
)
}
fn render_tag(state: State) {
use _ <- nibble.then(nibble.string(render_token))
use _ <- nibble.then(nibble.whitespace())
use filename <- nibble.then(filename())
use _ <- nibble.then(nibble.whitespace())
nibble.many(render_binding(), render_separator())
|> nibble.map(fn(bindings) {
State(
..state,
current_nodes: [
ast.Dynamic(ast.Render(filename, bindings)),
..state.current_nodes
],
)
})
}
fn end_tag(state: State) {
use _ <- nibble.then(nibble.string(end_token))
let outer_state =
option.lazy_unwrap(state.outer_state, fn() { empty_state() })
case state.current_tag {
Some(If(var, if_true)) -> {
let #(if_true, state) = case if_true {
Some(if_true_nodes) -> #(if_true_nodes, state)
None -> #(
list.reverse(state.current_nodes),
State(..state, current_nodes: []),
)
}
let if_false = list.reverse(state.current_nodes)
nibble.succeed(
State(
..outer_state,
current_nodes: [
ast.Dynamic(ast.If(var, if_true, if_false)),
..outer_state.current_nodes
],
),
)
}
Some(Iter(over, binding)) ->
nibble.succeed(
State(
..outer_state,
current_nodes: [
ast.Dynamic(ast.Iter(
over,
binding,
list.reverse(state.current_nodes),
)),
..outer_state.current_nodes
],
),
)
_else -> nibble.fail("Expected `if` or `for` tag for `end` tag")
}
}
fn else_tag(state: State) {
use _ <- nibble.then(nibble.string(else_token))
case state.current_tag {
Some(If(var, None)) ->
nibble.succeed(
State(
..state,
current_tag: Some(If(var, Some(list.reverse(state.current_nodes)))),
current_nodes: [],
),
)
_else -> nibble.fail("Expected `if` to match `else` tag")
}
}
fn stringify_current_text(state: State) {
State(
..state,
current_nodes: [
ast.Text(string_builder.to_string(state.current_text)),
..state.current_nodes
],
current_text: string_builder.new(),
)
}
fn variable_name() {
nibble.take_if_and_while(
fn(grapheme) { predicates.is_alphanum(grapheme) || grapheme == "_" },
"a variable name",
)
}
fn field_access() {
nibble.take_if_and_while(
fn(grapheme) { predicates.is_alphanum(grapheme) || grapheme == "_" },
"a field accessor",
)
}
fn index_access() {
nibble.take_if_and_while(predicates.is_digit, "an index accessor")
}
fn accessors() {
use accessors <- nibble.map(nibble.many(
nibble.one_of([field_access(), index_access()]),
nibble.string(access_separator),
))
list.map(
accessors,
fn(accessor) {
case int.parse(accessor) {
Ok(data) -> IndexAccess(data)
Error(_) -> FieldAccess(accessor)
}
},
)
}
fn variable() {
use name <- nibble.then(variable_name())
use accessors <- nibble.then(nibble.one_of([
{
use _ <- nibble.then(nibble.string(access_separator))
use accs <- nibble.then(accessors())
nibble.succeed(accs)
},
nibble.succeed([]),
]))
nibble.succeed(list.fold(
accessors,
ast.Assign(name),
fn(acc, accessor) {
case accessor {
FieldAccess(field) -> ast.FieldAccess(acc, field)
IndexAccess(index) -> ast.IndexAccess(acc, index)
}
},
))
}
fn filename() {
nibble.take_if_and_while(
fn(grapheme) {
predicates.is_alphanum(grapheme) || grapheme == "_" || grapheme == "-" || grapheme == "."
},
"a filename without spaces",
)
}
fn render_binding() {
use to <- nibble.then(variable_name())
use _ <- nibble.then(nibble.string(render_binding_separator))
use _ <- nibble.then(nibble.whitespace())
use from <- nibble.then(variable())
nibble.succeed(#(from, to))
}
fn render_separator() {
use _ <- nibble.then(nibble.string(render_pair_separator))
use _ <- nibble.then(nibble.whitespace())
nibble.succeed(Nil)
}

View file

@ -0,0 +1,294 @@
//// The renderer is given the template AST and some assigns, which it will
//// render into a string output.
import gleam/map.{Map}
import gleam/int
import gleam/string_builder.{StringBuilder}
import gleam/result
import gleam/list
import glemplate/assigns.{AssignData, Assigns}
import glemplate/result as result_helpers
import glemplate/ast
pub type RenderError {
/// A referenced assign was not found.
AssignNotFound(assign: ast.Var, assigns: Assigns)
/// A referenced assign was not iterable.
AssignNotIterable(assign: ast.Var, assigns: Assigns)
/// A referenced assign could not be stringified.
AssignNotStringifiable(assign: ast.Var, assigns: Assigns)
AssignFieldNotFound(assign: ast.Var, field: String, assigns: Assigns)
AssignNotFieldAccessible(assign: ast.Var, assigns: Assigns)
AssignIndexOutOfBounds(assign: ast.Var, index: Int, assigns: Assigns)
AssignNotIndexable(assign: ast.Var, assigns: Assigns)
/// A referenced child template was not found in the template cache.
ChildTemplateNotFound(tpl_name: ast.TemplateName)
}
pub type StringifyError {
StringifyError
}
type AssignResult =
Result(AssignData, RenderError)
pub type RenderResult =
Result(StringBuilder, RenderError)
/// Function to encode potentially dangerous contents into the template in a
/// safe way. E.g. encode text with HTML entities in an HTML template.
pub type EncoderFn =
fn(String) -> StringBuilder
/// A cache of templates to use for rendering child templates.
pub type TemplateCache =
Map(ast.TemplateName, ast.Template)
pub type RenderOptions {
RenderOptions(encoder: EncoderFn, template_cache: TemplateCache)
}
/// Render given template with the assigns and options.
pub fn render(
template: ast.Template,
assigns: Assigns,
opts: RenderOptions,
) -> RenderResult {
render_nodes(template.nodes, assigns, string_builder.new(), opts)
}
fn render_nodes(
nodes: ast.NodeList,
assigns: Assigns,
acc: StringBuilder,
options: RenderOptions,
) -> RenderResult {
result_helpers.fold(
nodes,
acc,
fn(acc, node) { render_node(node, assigns, acc, options) },
)
}
fn render_node(
node: ast.Node,
assigns: Assigns,
acc: StringBuilder,
options: RenderOptions,
) -> RenderResult {
case node {
ast.Text(str) -> Ok(string_builder.append(acc, str))
ast.Dynamic(dynamic) -> render_dynamic(dynamic, assigns, acc, options)
ast.Nodes(nodes) -> render_nodes(nodes, assigns, acc, options)
}
}
fn render_dynamic(
node: ast.DynamicNode,
assigns: Assigns,
acc: StringBuilder,
options: RenderOptions,
) -> RenderResult {
case node {
ast.Output(var) ->
get_assign(assigns, var)
|> result.then(fn(data) {
render_output(data, False, var, assigns, acc, options)
})
ast.RawOutput(var) ->
get_assign(assigns, var)
|> result.then(fn(data) {
render_output(data, True, var, assigns, acc, options)
})
ast.If(var, if_true, if_false) ->
get_assign(assigns, var)
|> result.then(fn(data) {
render_if(data, if_true, if_false, assigns, acc, options)
})
ast.Iter(over, binding, nodes) ->
get_assign(assigns, over)
|> result.then(fn(data) {
render_iter(data, over, binding, nodes, assigns, acc, options)
})
ast.Render(tpl, assigns_map) ->
render_child(tpl, assigns, assigns_map, acc, options)
}
}
fn render_output(
data: AssignData,
raw: Bool,
var: ast.Var,
assigns: Assigns,
acc: StringBuilder,
options: RenderOptions,
) -> RenderResult {
let str_result = stringify(data)
case str_result {
Ok(str) -> {
let out = case raw {
True -> string_builder.append(acc, str)
False -> string_builder.append_builder(acc, options.encoder(str))
}
Ok(out)
}
Error(_) -> Error(AssignNotStringifiable(assign: var, assigns: assigns))
}
}
fn render_if(
data: AssignData,
if_true: ast.NodeList,
if_false: ast.NodeList,
assigns: Assigns,
acc: StringBuilder,
options: RenderOptions,
) -> RenderResult {
case data {
assigns.Bool(False) -> render_nodes(if_false, assigns, acc, options)
_truthy -> render_nodes(if_true, assigns, acc, options)
}
}
fn render_iter(
data: AssignData,
over: ast.Var,
binding: ast.VarName,
nodes: ast.NodeList,
assigns: Assigns,
acc: StringBuilder,
options: RenderOptions,
) -> RenderResult {
case data {
assigns.List(items) ->
result_helpers.fold(
items,
acc,
fn(output, item) {
let new_assigns = map.insert(assigns, binding, item)
render_nodes(nodes, new_assigns, output, options)
},
)
_other -> Error(AssignNotIterable(assign: over, assigns: assigns))
}
}
fn render_child(
template_name: ast.TemplateName,
assigns: Assigns,
assigns_map: List(#(ast.Var, ast.VarName)),
acc: StringBuilder,
options: RenderOptions,
) -> RenderResult {
try template =
map.get(options.template_cache, template_name)
|> result.replace_error(ChildTemplateNotFound(tpl_name: template_name))
try new_assigns =
result_helpers.fold(
assigns_map,
map.new(),
fn(acc, mapping) {
let #(from, to) = mapping
try assign = get_assign(assigns, from)
Ok(map.insert(acc, to, assign))
},
)
render_nodes(template.nodes, new_assigns, acc, options)
}
fn get_assign(assigns: Assigns, var: ast.Var) -> AssignResult {
try data = case var {
ast.Assign(name) -> {
let get_result = map.get(assigns, name)
case get_result {
Ok(data) -> Ok(data)
Error(_) -> Error(AssignNotFound(assign: var, assigns: assigns))
}
}
ast.FieldAccess(container, field) -> {
try data = get_assign(assigns, container)
access_field(data, field, var, assigns)
}
ast.IndexAccess(container, index) -> {
try data = get_assign(assigns, container)
access_index(data, index, var, assigns)
}
}
// In case the data is a lazy function, it must be resolved
Ok(resolve_lazy(data))
}
fn access_field(
container: AssignData,
field: String,
accessor: ast.Var,
assigns: Assigns,
) -> AssignResult {
case container {
assigns.Map(map) ->
case map.get(map, field) {
Ok(data) -> Ok(data)
Error(_) ->
Error(AssignFieldNotFound(
assign: accessor,
field: field,
assigns: assigns,
))
}
_other ->
Error(AssignNotFieldAccessible(assign: accessor, assigns: assigns))
}
}
fn access_index(
container: AssignData,
index: Int,
accessor: ast.Var,
assigns: Assigns,
) -> AssignResult {
case container {
assigns.List(l) ->
case list.at(l, index) {
Ok(data) -> Ok(data)
Error(_) ->
Error(AssignIndexOutOfBounds(
assign: accessor,
index: index,
assigns: assigns,
))
}
_other -> Error(AssignNotIndexable(assign: accessor, assigns: assigns))
}
}
fn stringify(data: AssignData) -> Result(String, StringifyError) {
case data {
assigns.String(s) -> Ok(s)
assigns.Int(i) -> Ok(int.to_string(i))
_other -> Error(StringifyError)
}
}
fn resolve_lazy(data: AssignData) -> AssignData {
case data {
assigns.Lazy(lazy_fn) -> resolve_lazy(lazy_fn())
other -> other
}
}

View file

@ -0,0 +1,24 @@
//// Helper utilities for result types.
import gleam/list.{Continue, Stop}
/// Fold over a list, stopping at the first error encountered.
pub fn fold(
items: List(a),
init: b,
folder: fn(b, a) -> Result(b, c),
) -> Result(b, c) {
list.fold_until(
items,
Ok(init),
fn(acc, item) {
assert Ok(acc) = acc
let res = folder(acc, item)
case res {
Ok(next_acc) -> Continue(Ok(next_acc))
Error(error) -> Stop(Error(error))
}
},
)
}

24
src/glemplate/text.gleam Normal file
View file

@ -0,0 +1,24 @@
//// Utilities for rendering plain text templates.
import gleam/string_builder
import glemplate/renderer
import glemplate/ast
import glemplate/assigns
/// An encoder that just returns the content as-is.
pub fn encode(str: String) {
string_builder.from_string(str)
}
/// Render a plain text template.
pub fn render(
template: ast.Template,
assigns: assigns.Assigns,
template_cache: renderer.TemplateCache,
) -> renderer.RenderResult {
renderer.render(
template,
assigns,
renderer.RenderOptions(encoder: encode, template_cache: template_cache),
)
}

View file

@ -0,0 +1,5 @@
import gleeunit
pub fn main() {
gleeunit.main()
}

24
test/html_test.gleam Normal file
View file

@ -0,0 +1,24 @@
import gleam/map
import gleam/string_builder
import gleeunit/should
import glemplate/parser
import glemplate/html
import glemplate/assigns
pub fn encoding_test() {
let template = "<b>Welcome, <%= name %>!</b> <%= raw name %>"
assert Ok(tpl) = parser.parse_to_template(template, "input.html.glemp")
let template_cache = map.new()
let assigns =
assigns.new()
|> assigns.add_string("name", "||<xXx_KillaBoy_69>||")
assert Ok(result) = html.render(tpl, assigns, template_cache)
should.equal(
string_builder.to_string(result),
"<b>Welcome, &vert;&vert;&lt;xXx&lowbar;KillaBoy&lowbar;69&gt;&vert;&vert;!</b> ||<xXx_KillaBoy_69>||",
)
}

362
test/parser_test.gleam Normal file
View file

@ -0,0 +1,362 @@
import gleeunit/should
import glemplate/parser
import glemplate/ast.{
Assign, Dynamic, FieldAccess, If, IndexAccess, Iter, Nodes, Output, RawOutput,
Render, Text,
}
pub fn output_test() {
let template = "<%= aurora %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[Nodes([]), Text(""), Dynamic(Output(Assign("aurora"))), Text("")],
)
}
pub fn raw_output_test() {
let template = "<%= raw aurora %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[Nodes([]), Text(""), Dynamic(RawOutput(Assign("aurora"))), Text("")],
)
}
pub fn if_test() {
let template = "<% if martin %>Hello, I'm Martin<% end %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(If(Assign("martin"), [Text("Hello, I'm Martin")], [])),
Text(""),
],
)
}
pub fn if_else_test() {
let template =
"<% if martin %>Hello, I'm Martin<% else %>Sorry, I think you've mistaken me for someone else<% end %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(If(
Assign("martin"),
[Text("Hello, I'm Martin")],
[Text("Sorry, I think you've mistaken me for someone else")],
)),
Text(""),
],
)
}
pub fn for_test() {
let template = "<% for item in shopping %>- <%= item %><% end %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(Iter(
Assign("shopping"),
"item",
[Text("- "), Dynamic(Output(Assign("item"))), Text("")],
)),
Text(""),
],
)
}
pub fn render_test() {
let template = "<% render foo %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[Nodes([]), Text(""), Dynamic(Render("foo", [])), Text("")],
)
}
pub fn render_1_binding_test() {
let template = "<% render foo a: b %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(Render("foo", [#(Assign("b"), "a")])),
Text(""),
],
)
}
pub fn render_n_bindings_test() {
let template = "<% render foo a: b, c: d, e: f %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(Render(
"foo",
[#(Assign("b"), "a"), #(Assign("d"), "c"), #(Assign("f"), "e")],
)),
Text(""),
],
)
}
pub fn field_access_test() {
let template = "<%= aurora.conqueror %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(Output(FieldAccess(Assign("aurora"), "conqueror"))),
Text(""),
],
)
}
pub fn index_access_test() {
let template = "<%= aurora.0 %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(Output(IndexAccess(Assign("aurora"), 0))),
Text(""),
],
)
}
pub fn chained_access_test() {
let template = "<%= aurora.albums.0.publish_year %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(Output(FieldAccess(
IndexAccess(FieldAccess(Assign("aurora"), "albums"), 0),
"publish_year",
))),
Text(""),
],
)
}
pub fn raw_field_access_test() {
let template = "<%= raw aurora.conqueror %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(RawOutput(FieldAccess(Assign("aurora"), "conqueror"))),
Text(""),
],
)
}
pub fn if_field_access_test() {
let template = "<% if user.is_martin %>Hello, I'm Martin<% end %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(If(
FieldAccess(Assign("user"), "is_martin"),
[Text("Hello, I'm Martin")],
[],
)),
Text(""),
],
)
}
pub fn for_access_test() {
let template = "<% for item in user.shopping_lists.0 %>- <%= item %><% end %>"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text(""),
Dynamic(Iter(
IndexAccess(FieldAccess(Assign("user"), "shopping_lists"), 0),
"item",
[Text("- "), Dynamic(Output(Assign("item"))), Text("")],
)),
Text(""),
],
)
}
pub fn complex_test() {
let template =
"<h1>HIGH SCORES:</h1>
<ul>
<% for score in scores %>
<li>
<% if score %>
<% render score_tpl.html.glemp score: score %>
<% end %>
</li>
<% end %>
</ul>
You can create comments by using comment tags: <%%!-- Comment --%>
<%!-- The pyramid: --%>
<% if user %>
<% if other_user %>
<% if third_user %>
<% for item in user.items %>
- User's item: <%= item %>
<% for ou_item in other_user.items %>
- Other user's item: <%= ou_item %>
<% for tu_item in third_user.items %>
- Third user's item: <%= tu_item %>
<% render item_combos.html.glemp item_1: item, item_2: ou_item, item_3: tu_item %>
<% end %>
<% end %>
<% end %>
<% else %>
Third user not found!
<% end %>
<% else %>
Second user not found!
<% end %>
<% else %>
User not found!
<% end %>
"
assert Ok(parsed) = parser.parse(template)
should.equal(
parsed,
[
Nodes([]),
Text("<h1>HIGH SCORES:</h1>\n<ul>\n "),
Dynamic(Iter(
Assign("scores"),
"score",
[
Text("\n <li>\n "),
Dynamic(If(
Assign("score"),
[
Text("\n "),
Dynamic(Render(
"score_tpl.html.glemp",
[#(Assign("score"), "score")],
)),
Text("\n "),
],
[],
)),
Text("\n </li>\n "),
],
)),
Text("\n</ul>\n\nYou can create comments by using comment tags: "),
Text("<%"),
Text("!-- Comment --%>\n\n"),
Text("\n"),
Dynamic(If(
Assign("user"),
[
Text("\n "),
Dynamic(If(
Assign("other_user"),
[
Text("\n "),
Dynamic(If(
Assign("third_user"),
[
Text("\n "),
Dynamic(Iter(
FieldAccess(Assign("user"), "items"),
"item",
[
Text("\n - User's item: "),
Dynamic(Output(Assign("item"))),
Text("\n "),
Dynamic(Iter(
FieldAccess(Assign("other_user"), "items"),
"ou_item",
[
Text("\n - Other user's item: "),
Dynamic(Output(Assign("ou_item"))),
Text("\n "),
Dynamic(Iter(
FieldAccess(Assign("third_user"), "items"),
"tu_item",
[
Text("\n - Third user's item: "),
Dynamic(Output(Assign("tu_item"))),
Text("\n\n "),
Dynamic(Render(
"item_combos.html.glemp",
[
#(Assign("item"), "item_1"),
#(Assign("ou_item"), "item_2"),
#(Assign("tu_item"), "item_3"),
],
)),
Text("\n "),
],
)),
Text("\n "),
],
)),
Text("\n "),
],
)),
Text("\n "),
],
[Text("\n Third user not found!\n ")],
)),
Text("\n "),
],
[Text("\n Second user not found!\n ")],
)),
Text("\n"),
],
[Text("\n User not found!\n")],
)),
Text("\n"),
],
)
}

420
test/renderer_test.gleam Normal file
View file

@ -0,0 +1,420 @@
import gleam/map
import gleam/string_builder
import gleeunit/should
import glemplate/assigns.{Assigns, Bool, Int, Lazy, List, Map, String}
import glemplate/ast.{
Assign, Dynamic, FieldAccess, If, IndexAccess, Iter, NodeList, Nodes, Output,
RawOutput, Render, Template, Text,
}
import glemplate/renderer
pub fn empty_test() {
should.equal(render_to_string([], map.new()), "")
}
pub fn text_test() {
should.equal(
render_to_string([Text("foo"), Text(" bar "), Text("baz")], map.new()),
"foo bar baz",
)
}
pub fn nested_test() {
should.equal(
render_to_string(
[Nodes([Nodes([Text("foo")])]), Nodes([Nodes([Nodes([])])])],
map.new(),
),
"foo",
)
}
pub fn var_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", String("bar"))
should.equal(
render_to_string(
[Text("<b>"), Dynamic(Output(Assign("foo"))), Text("</b>")],
assigns,
),
"<b>bar</b>",
)
}
pub fn encoder_test() {
assert Ok(builder) =
renderer.render(
Template(
name: "TestTpl",
nodes: [
Dynamic(Output(Assign("foo"))),
Dynamic(RawOutput(Assign("foo"))),
],
),
map.new()
|> map.insert("foo", String("🙂")),
renderer.RenderOptions(
encoder: fn(_content) { string_builder.from_string("🙃") },
template_cache: map.new(),
),
)
should.equal(string_builder.to_string(builder), "🙃🙂")
}
pub fn int_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", Int(1_000_000))
should.equal(
render_to_string([Dynamic(Output(Assign("foo")))], assigns),
"1000000",
)
}
pub fn lazy_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", Lazy(fn() { String("awesome") }))
should.equal(
render_to_string([Dynamic(Output(Assign("foo")))], assigns),
"awesome",
)
}
pub fn if_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", Bool(True))
should.equal(
render_to_string(
[Dynamic(If(Assign("foo"), [Text("yes")], [Text("no")]))],
assigns,
),
"yes",
)
}
// Lazy function should not be called in code path that is not entered
pub fn if_lazy_test() {
let assigns: Assigns =
map.from_list([
#("foo", Bool(True)),
#(
"lazy",
Lazy(fn() {
should.fail()
Bool(False)
}),
),
])
should.equal(
render_to_string(
[
Dynamic(If(
Assign("foo"),
[Text("yes")],
[Dynamic(Output(Assign("lazy")))],
)),
],
assigns,
),
"yes",
)
}
pub fn iter_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", List([String("first"), Int(2), String("third")]))
should.equal(
render_to_string(
[
Dynamic(Iter(
Assign("foo"),
"bar",
[Text("* "), Dynamic(Output(Assign("bar"))), Text("\n")],
)),
],
assigns,
),
"* first\n* 2\n* third\n",
)
}
pub fn render_test() {
let assigns: Assigns =
map.new()
|> map.insert("parent_assign", Bool(False))
should.equal(
render_to_string(
[
Text("child tpl -- "),
Dynamic(Render("ChildTpl", [#(Assign("parent_assign"), "child_assign")])),
Text(" -- end child tpl"),
],
assigns,
),
"child tpl -- child_assign is false -- end child tpl",
)
}
pub fn field_access_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", Map(map.from_list([#("bar", String("baz"))])))
should.equal(
render_to_string(
[
Text("<b>"),
Dynamic(Output(FieldAccess(Assign("foo"), "bar"))),
Text("</b>"),
],
assigns,
),
"<b>baz</b>",
)
}
pub fn index_access_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", List([String("bar"), String("baz")]))
should.equal(
render_to_string(
[
Text("<b>"),
Dynamic(Output(IndexAccess(Assign("foo"), 1))),
Text("</b>"),
],
assigns,
),
"<b>baz</b>",
)
}
pub fn complex_test() {
let assigns: Assigns =
map.from_list([
#(
"user",
Map(map.from_list([
#("name", String("Nicd")),
#("registered", Lazy(fn() { String("2023-01-31") })),
#("points", Int(13_992_355_986)),
#("level", Int(32)),
#("paid", Bool(True)),
#(
"top_languages",
List([
String("Gleam"),
String("Elixir"),
String("TypeScript"),
String("JavaScript"),
String("Python"),
String("YaBasic"),
String("VHDL"),
String("GTLT"),
]),
),
])),
),
])
let str =
render_to_string(
[
Text("User information:\n"),
Text("\n"),
Nodes([
Dynamic(Output(FieldAccess(Assign("user"), "name"))),
Text(" - "),
Dynamic(Render(
"points.txt.glemp",
assigns_map: [
#(FieldAccess(Assign("user"), "level"), "level"),
#(FieldAccess(Assign("user"), "points"), "points"),
],
)),
Text("\n"),
]),
Text("Registered on "),
Dynamic(Output(FieldAccess(Assign("user"), "registered"))),
Dynamic(If(
FieldAccess(Assign("user"), "paid"),
[Text(" (awesome supporter).")],
[Text(".")],
)),
Text("\n\n"),
Text("User's top languages\n"),
Text("--------------------\n"),
Dynamic(Iter(
FieldAccess(Assign("user"), "top_languages"),
"lang",
[Text("* "), Dynamic(Output(Assign("lang"))), Text("\n")],
)),
],
assigns,
)
should.equal(
str,
"User information:
Nicd - 13992355986 XP (level 32)
Registered on 2023-01-31 (awesome supporter).
User's top languages
--------------------
* Gleam
* Elixir
* TypeScript
* JavaScript
* Python
* YaBasic
* VHDL
* GTLT
",
)
}
pub fn hayleigh_test() {
let assigns: Assigns =
map.from_list([
#("my_var", String(":)")),
#("pekka", Bool(False)),
#("some_items", List([String("first"), Int(2), String("third")])),
#("user", String("Nicd")),
])
let str =
render_to_string(
[
Text("<% <-- Escaped\n\n\n\nJotain tämmöstä <b>"),
Dynamic(Output(Assign("my_var"))),
Text("</b>.\nRaw variable: "),
Dynamic(RawOutput(Assign("my_var"))),
Text(".\n\n"),
Dynamic(If(
Assign("pekka"),
[Text("Olen pekka.\n")],
[Text("En ole pekka.\n")],
)),
Text("\n\n"),
Dynamic(Iter(
Assign("some_items"),
"item",
[Text(" * "), Dynamic(Output(Assign("item"))), Text("\n")],
)),
Text("\n\n\n"),
Dynamic(Render("some_template", [#(Assign("user"), "user")])),
],
assigns,
)
should.equal(
str,
"<% <-- Escaped\n\n\n\nJotain tämmöstä <b>:)</b>.\nRaw variable: :).\n\nEn ole pekka.\n\n\n * first\n * 2\n * third\n\n\n\n",
)
}
pub fn unstringifiable_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", Bool(True))
should.be_error(render_tpl([Dynamic(Output(Assign("foo")))], assigns))
}
pub fn missing_assign_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo2", String("bar"))
should.be_error(render_tpl([Dynamic(Output(Assign("foo")))], assigns))
}
pub fn not_iterable_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", String("bar"))
should.be_error(render_tpl(
[Dynamic(Iter(Assign("foo"), "binding", []))],
assigns,
))
}
pub fn missing_child_test() {
let assigns: Assigns = map.new()
should.be_error(render_tpl([Dynamic(Render("no", []))], assigns))
}
fn render_to_string(nodes: NodeList, assigns: Assigns) {
assert Ok(builder) = render_tpl(nodes, assigns)
string_builder.to_string(builder)
}
fn render_tpl(nodes: NodeList, assigns: Assigns) {
renderer.render(
Template(name: "TestTpl", nodes: nodes),
assigns,
renderer.RenderOptions(
encoder: fn(content) { string_builder.from_string(content) },
template_cache: map.from_list([
#(
"ChildTpl",
Template(
name: "ChildTpl",
nodes: [
Dynamic(If(
Assign("child_assign"),
[],
[Text("child_assign is false")],
)),
],
),
),
#(
"points.txt.glemp",
Template(
name: "points.txt.glemp",
nodes: [
Dynamic(Output(Assign("points"))),
Text(" XP (level "),
Dynamic(Output(Assign("level"))),
Text(")"),
],
),
),
#("some_template", Template(name: "some_template", nodes: [])),
]),
),
)
}
pub fn missing_field_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", Map(map.from_list([#("bar", String("baz"))])))
should.be_error(render_tpl(
[
Text("<b>"),
Dynamic(Output(FieldAccess(Assign("foo"), "noooooo"))),
Text("</b>"),
],
assigns,
))
}
pub fn out_of_bounds_test() {
let assigns: Assigns =
map.new()
|> map.insert("foo", List([String("bar"), String("baz")]))
should.be_error(render_tpl(
[
Text("<b>"),
Dynamic(Output(IndexAccess(Assign("foo"), 100_000))),
Text("</b>"),
],
assigns,
))
}

24
test/text_test.gleam Normal file
View file

@ -0,0 +1,24 @@
import gleam/map
import gleam/string_builder
import gleeunit/should
import glemplate/parser
import glemplate/text
import glemplate/assigns
pub fn encoding_test() {
let template = "**Welcome, <%= name %>!** <%= raw name %>"
assert Ok(tpl) = parser.parse_to_template(template, "input.txt.glemp")
let template_cache = map.new()
let assigns =
assigns.new()
|> assigns.add_string("name", "||<xXx_KillaBoy_69>||")
assert Ok(result) = text.render(tpl, assigns, template_cache)
should.equal(
string_builder.to_string(result),
"**Welcome, ||<xXx_KillaBoy_69>||!** ||<xXx_KillaBoy_69>||",
)
}