Initial commit
This commit is contained in:
commit
c2117bc6ff
19 changed files with 1926 additions and 0 deletions
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
*.beam
|
||||
*.ez
|
||||
build
|
||||
erl_crash.dump
|
2
.tool-versions
Normal file
2
.tool-versions
Normal file
|
@ -0,0 +1,2 @@
|
|||
erlang 25.2.2
|
||||
gleam 0.26.2
|
4
CHANGELOG
Normal file
4
CHANGELOG
Normal file
|
@ -0,0 +1,4 @@
|
|||
1.0.0
|
||||
-----
|
||||
|
||||
Initial release.
|
19
LICENSE
Normal file
19
LICENSE
Normal 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
61
README.md
Normal 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, <Nicd>!</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
18
gleam.toml
Normal 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
15
manifest.toml
Normal 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"
|
59
src/glemplate/assigns.gleam
Normal file
59
src/glemplate/assigns.gleam
Normal 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
55
src/glemplate/ast.gleam
Normal 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
26
src/glemplate/html.gleam
Normal 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
486
src/glemplate/parser.gleam
Normal 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)
|
||||
}
|
294
src/glemplate/renderer.gleam
Normal file
294
src/glemplate/renderer.gleam
Normal 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
|
||||
}
|
||||
}
|
24
src/glemplate/result.gleam
Normal file
24
src/glemplate/result.gleam
Normal 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
24
src/glemplate/text.gleam
Normal 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),
|
||||
)
|
||||
}
|
5
test/glemplate_test.gleam
Normal file
5
test/glemplate_test.gleam
Normal file
|
@ -0,0 +1,5 @@
|
|||
import gleeunit
|
||||
|
||||
pub fn main() {
|
||||
gleeunit.main()
|
||||
}
|
24
test/html_test.gleam
Normal file
24
test/html_test.gleam
Normal 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, ||<xXx_KillaBoy_69>||!</b> ||<xXx_KillaBoy_69>||",
|
||||
)
|
||||
}
|
362
test/parser_test.gleam
Normal file
362
test/parser_test.gleam
Normal 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
420
test/renderer_test.gleam
Normal 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
24
test/text_test.gleam
Normal 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>||",
|
||||
)
|
||||
}
|
Loading…
Reference in a new issue