commit c2117bc6ffae2d86559f983b15e7dbab335afb22 Author: Mikko Ahlroth Date: Sun Feb 26 22:48:34 2023 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..170cca9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +*.beam +*.ez +build +erl_crash.dump diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..5a35570 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,2 @@ +erlang 25.2.2 +gleam 0.26.2 diff --git a/CHANGELOG b/CHANGELOG new file mode 100644 index 0000000..c3a5333 --- /dev/null +++ b/CHANGELOG @@ -0,0 +1,4 @@ +1.0.0 +----- + +Initial release. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..804d1e2 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2608bc6 --- /dev/null +++ b/README.md @@ -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 = "Welcome, <%= name %>!" + +assert Ok(tpl) = parser.parse_to_template(template, "input.html.glemp") +let assigns = assigns.from_list([#("name", assigns.String(""))]) +let template_cache = map.new() +html.render(tpl, assigns, template_cache) // "Welcome, <Nicd>!" +``` + +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 . diff --git a/gleam.toml b/gleam.toml new file mode 100644 index 0000000..a986aa1 --- /dev/null +++ b/gleam.toml @@ -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" diff --git a/manifest.toml b/manifest.toml new file mode 100644 index 0000000..e26145e --- /dev/null +++ b/manifest.toml @@ -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" diff --git a/src/glemplate/assigns.gleam b/src/glemplate/assigns.gleam new file mode 100644 index 0000000..839f939 --- /dev/null +++ b/src/glemplate/assigns.gleam @@ -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)) +} diff --git a/src/glemplate/ast.gleam b/src/glemplate/ast.gleam new file mode 100644 index 0000000..d98f28c --- /dev/null +++ b/src/glemplate/ast.gleam @@ -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 diff --git a/src/glemplate/html.gleam b/src/glemplate/html.gleam new file mode 100644 index 0000000..cd3a76e --- /dev/null +++ b/src/glemplate/html.gleam @@ -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), + ) +} diff --git a/src/glemplate/parser.gleam b/src/glemplate/parser.gleam new file mode 100644 index 0000000..9e862b7 --- /dev/null +++ b/src/glemplate/parser.gleam @@ -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) +} diff --git a/src/glemplate/renderer.gleam b/src/glemplate/renderer.gleam new file mode 100644 index 0000000..36bfc35 --- /dev/null +++ b/src/glemplate/renderer.gleam @@ -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 + } +} diff --git a/src/glemplate/result.gleam b/src/glemplate/result.gleam new file mode 100644 index 0000000..1a6ced9 --- /dev/null +++ b/src/glemplate/result.gleam @@ -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)) + } + }, + ) +} diff --git a/src/glemplate/text.gleam b/src/glemplate/text.gleam new file mode 100644 index 0000000..9aede4c --- /dev/null +++ b/src/glemplate/text.gleam @@ -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), + ) +} diff --git a/test/glemplate_test.gleam b/test/glemplate_test.gleam new file mode 100644 index 0000000..ecd12ad --- /dev/null +++ b/test/glemplate_test.gleam @@ -0,0 +1,5 @@ +import gleeunit + +pub fn main() { + gleeunit.main() +} diff --git a/test/html_test.gleam b/test/html_test.gleam new file mode 100644 index 0000000..87afcfb --- /dev/null +++ b/test/html_test.gleam @@ -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 = "Welcome, <%= name %>! <%= 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", "||||") + + assert Ok(result) = html.render(tpl, assigns, template_cache) + + should.equal( + string_builder.to_string(result), + "Welcome, ||<xXx_KillaBoy_69>||! ||||", + ) +} diff --git a/test/parser_test.gleam b/test/parser_test.gleam new file mode 100644 index 0000000..ee228cb --- /dev/null +++ b/test/parser_test.gleam @@ -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 = + "

HIGH SCORES:

+
    + <% for score in scores %> +
  • + <% if score %> + <% render score_tpl.html.glemp score: score %> + <% end %> +
  • + <% end %> +
+ +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("

HIGH SCORES:

\n
    \n "), + Dynamic(Iter( + Assign("scores"), + "score", + [ + Text("\n
  • \n "), + Dynamic(If( + Assign("score"), + [ + Text("\n "), + Dynamic(Render( + "score_tpl.html.glemp", + [#(Assign("score"), "score")], + )), + Text("\n "), + ], + [], + )), + Text("\n
  • \n "), + ], + )), + Text("\n
\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"), + ], + ) +} diff --git a/test/renderer_test.gleam b/test/renderer_test.gleam new file mode 100644 index 0000000..2e362d9 --- /dev/null +++ b/test/renderer_test.gleam @@ -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(""), Dynamic(Output(Assign("foo"))), Text("")], + assigns, + ), + "bar", + ) +} + +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(""), + Dynamic(Output(FieldAccess(Assign("foo"), "bar"))), + Text(""), + ], + assigns, + ), + "baz", + ) +} + +pub fn index_access_test() { + let assigns: Assigns = + map.new() + |> map.insert("foo", List([String("bar"), String("baz")])) + should.equal( + render_to_string( + [ + Text(""), + Dynamic(Output(IndexAccess(Assign("foo"), 1))), + Text(""), + ], + assigns, + ), + "baz", + ) +} + +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ä "), + Dynamic(Output(Assign("my_var"))), + Text(".\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ä :).\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(""), + Dynamic(Output(FieldAccess(Assign("foo"), "noooooo"))), + Text(""), + ], + 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(""), + Dynamic(Output(IndexAccess(Assign("foo"), 100_000))), + Text(""), + ], + assigns, + )) +} diff --git a/test/text_test.gleam b/test/text_test.gleam new file mode 100644 index 0000000..1bb7fcf --- /dev/null +++ b/test/text_test.gleam @@ -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", "||||") + + assert Ok(result) = text.render(tpl, assigns, template_cache) + + should.equal( + string_builder.to_string(result), + "**Welcome, ||||!** ||||", + ) +}