diff --git a/README.md b/README.md index 3244e95..98b948b 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Publish](https://github.com/cogna-dev/x/actions/workflows/publish.yml/badge.svg)](https://github.com/cogna-dev/x/actions/workflows/publish.yml) [![Document](https://github.com/cogna-dev/x/actions/workflows/document.yml/badge.svg)](https://github.com/cogna-dev/x/actions/workflows/document.yml) [![MoonBit](https://img.shields.io/badge/MoonBit-ready-8A2BE2)](https://www.moonbitlang.com/) -[![Version](https://img.shields.io/badge/version-v0.1.1-2ea44f)](https://github.com/cogna-dev/x/releases) +[![Version](https://img.shields.io/badge/version-v0.1.3-2ea44f)](https://github.com/cogna-dev/x/releases) ```text _________ ____ _ _______ ____ _______ _ __ @@ -30,6 +30,7 @@ Experimental monorepo for real-world [MoonBit](https://www.moonbitlang.com/) pac |---------|-------------| | [`cogna-dev/x/hcl`](src/hcl/) | HCL parser built with `cogna-dev/parkit/nom` | | [`cogna-dev/x/logo`](src/logo/) | Prints the ASCII art for "Cogna" | +| [`cogna-dev/x/mustache`](src/mustache/) | Mustache template renderer with spec-based tests | | [`cogna-dev/x/sarif`](src/sarif/) | SARIF 2.1.0 static types with JSON encode/decode | ## Development diff --git a/moon.mod.json b/moon.mod.json index efbf97c..70e3e7f 100644 --- a/moon.mod.json +++ b/moon.mod.json @@ -1,6 +1,6 @@ { "name": "cogna-dev/x", - "version": "0.1.2", + "version": "0.1.3", "deps": { "cogna-dev/parkit": "0.1.1" }, diff --git a/src/mustache/README.md b/src/mustache/README.md new file mode 100644 index 0000000..ee7d3eb --- /dev/null +++ b/src/mustache/README.md @@ -0,0 +1,79 @@ +# `cogna-dev/x/mustache` + +[![MoonBit](https://img.shields.io/badge/MoonBit-package-6C47FF)](https://www.moonbitlang.com/) +[![Mustache](https://img.shields.io/badge/template-Mustache-111827)](https://mustache.github.io/) +[![Spec](https://img.shields.io/badge/tests-official%20spec-16A34A)](https://github.com/mustache/spec) +[![Status](https://img.shields.io/badge/status-experimental-F59E0B)](../../README.md) + +```text + __ ___ __ __ + / |/ /_ ______/ /_____ _____/ /_ ___ +/ /|_/ / / / / __ / __/ // / _ / _ \/ _ \ +/_/ /_/\_,_/\_,_/_/\__\_,_/\_,_/\___/\___/ +``` + +Minimal Mustache rendering for MoonBit with practical, spec-driven behavior. + +## Features + +- Escaped interpolation: `{{name}}` +- Unescaped interpolation: `{{{name}}}`, `{{&name}}` +- Sections and inverted sections: `{{#x}}...{{/x}}`, `{{^x}}...{{/x}}` +- Dotted-name lookup and implicit iterator (`{{.}}`) +- Comments (`{{! ... }}`) +- Partials (`{{>partial}}`) +- Delimiter switching (`{{= <% %> =}}`) + +## Usage + +### API + +```moonbit +pub fn render(template : String, data : Json, partials? : Map[String, String]) -> String +``` + +### Basic interpolation + +```moonbit +let out = @mustache.render( + "Hello, {{subject}}!", + Json::object({ "subject": Json::string("world") }), +) +// out == "Hello, world!" +``` + +### Sections and dotted names + +```moonbit +let template = "{{#person}}{{name}} from {{meta.city}}{{/person}}" +let data = Json::object({ + "person": Json::object({ + "name": Json::string("Joe"), + "meta": Json::object({ "city": Json::string("Berlin") }), + }), +}) +let out = @mustache.render(template, data) +``` + +### Partials + +```moonbit +let template = "{{>card}}" +let data = Json::object({ "title": Json::string("Hello") }) +let partials = { "card": "[{{title}}]" } +let out = @mustache.render(template, data, partials=partials) +// out == "[Hello]" +``` + +## Specification coverage + +This package includes tests adapted from official Mustache spec cases: + +- interpolation +- sections +- inverted sections +- comments +- delimiters +- partials + +Reference: diff --git a/src/mustache/main.mbt b/src/mustache/main.mbt new file mode 100644 index 0000000..c349b3a --- /dev/null +++ b/src/mustache/main.mbt @@ -0,0 +1,359 @@ +///| +enum Node { + Text(String) + Variable(String, Bool) + Section(String, Array[Node], Bool) + Partial(String) +} derive(Debug, Eq) + +///| +pub fn render( + template : String, + data : Json, + partials? : Map[String, String], +) -> String { + let partial_map = match partials { + Some(p) => p + None => {} + } + let parsed_partials : Map[String, Array[Node]] = {} + let nodes = parse_template(template) + render_nodes(nodes, [data], partial_map, parsed_partials) +} + +///| +fn parse_template(template : String) -> Array[Node] { + let (nodes, _, _, _, _) = parse_nodes(template, 0, "{{", "}}", None) + nodes +} + +///| +fn parse_nodes( + template : String, + start : Int, + open : String, + close : String, + until : String?, +) -> (Array[Node], Int, String, String, Bool) { + let mut open = open + let mut close = close + let nodes : Array[Node] = [] + let mut pos = start + + while pos < template.length() { + let next_open = match find_from(template, open, pos) { + Some(i) => i + None => { + nodes.push(Node::Text(template[pos:].to_string())) + return (nodes, template.length(), open, close, false) + } + } + + if next_open > pos { + nodes.push(Node::Text(template[pos:next_open].to_string())) + } + + let tag_start = next_open + open.length() + + if open == "{{" && template[tag_start:].has_prefix("{") { + let content_start = tag_start + 1 + let triple_end = match find_from(template, "}}}", content_start) { + Some(i) => i + None => { + nodes.push(Node::Text(template[next_open:].to_string())) + return (nodes, template.length(), open, close, false) + } + } + let name = template[content_start:triple_end] + .to_string() + .trim() + .to_string() + nodes.push(Node::Variable(name, false)) + pos = triple_end + 3 + continue + } + + let tag_end = match find_from(template, close, tag_start) { + Some(i) => i + None => { + nodes.push(Node::Text(template[next_open:].to_string())) + return (nodes, template.length(), open, close, false) + } + } + + let tag = template[tag_start:tag_end].to_string().trim().to_string() + pos = tag_end + close.length() + + if tag.is_empty() { + continue + } + + let head = match tag.get_char(0) { + Some(c) => c + None => continue + } + match head { + '!' => continue + '&' => + nodes.push( + Node::Variable(tag[1:].to_string().trim().to_string(), false), + ) + '#' => { + let name = tag[1:].to_string().trim().to_string() + let (children, next_pos, next_open, next_close, _) = parse_nodes( + template, + pos, + open, + close, + Some(name), + ) + nodes.push(Node::Section(name, children, false)) + pos = next_pos + open = next_open + close = next_close + } + '^' => { + let name = tag[1:].to_string().trim().to_string() + let (children, next_pos, next_open, next_close, _) = parse_nodes( + template, + pos, + open, + close, + Some(name), + ) + nodes.push(Node::Section(name, children, true)) + pos = next_pos + open = next_open + close = next_close + } + '/' => { + let closing = tag[1:].to_string().trim().to_string() + match until { + Some(expected) if closing == expected => + return (nodes, pos, open, close, true) + _ => nodes.push(Node::Text(template[next_open:pos].to_string())) + } + } + '>' => nodes.push(Node::Partial(tag[1:].to_string().trim().to_string())) + '=' => + if tag.has_suffix("=") { + match + parse_delimiters( + tag[1:tag.length() - 1].to_string().trim().to_string(), + ) { + Some((next_open, next_close)) => { + open = next_open + close = next_close + } + None => () + } + } + _ => nodes.push(Node::Variable(tag, true)) + } + } + + (nodes, pos, open, close, false) +} + +///| +fn parse_delimiters(input : String) -> (String, String)? { + let parts = split_whitespace(input) + match (parts.get(0), parts.get(1)) { + (Some(open), Some(close)) => Some((open, close)) + _ => None + } +} + +///| +fn split_whitespace(input : String) -> Array[String] { + let parts : Array[String] = [] + let mut current : Array[Char] = [] + for c in input { + if c == ' ' || c == '\t' || c == '\n' || c == '\r' { + if current.length() > 0 { + parts.push(String::from_array(current)) + current = [] + } + } else { + current.push(c) + } + } + if current.length() > 0 { + parts.push(String::from_array(current)) + } + parts +} + +///| +fn find_from(source : String, needle : String, start : Int) -> Int? { + if start >= source.length() { + None + } else { + match source[start:].to_string().find(needle) { + Some(i) => Some(start + i) + None => None + } + } +} + +///| +fn render_nodes( + nodes : Array[Node], + stack : Array[Json], + partials : Map[String, String], + parsed_partials : Map[String, Array[Node]], +) -> String { + let mut out = "" + for node in nodes { + match node { + Node::Text(text) => out = out + text + Node::Variable(name, escaped) => { + let value = match lookup(stack, name) { + Some(v) => stringify_value(v) + None => "" + } + out = out + (if escaped { escape_html(value) } else { value }) + } + Node::Section(name, children, inverted) => { + let value = lookup(stack, name) + if inverted { + if is_falsey(value) { + out = out + render_nodes(children, stack, partials, parsed_partials) + } + } else { + match value { + Some(Array(items)) => + for item in items { + stack.push(item) + out = out + + render_nodes(children, stack, partials, parsed_partials) + ignore(stack.pop()) + } + Some(v) => + if is_truthy(v) { + stack.push(v) + out = out + + render_nodes(children, stack, partials, parsed_partials) + ignore(stack.pop()) + } + None => () + } + } + } + Node::Partial(name) => + match partials.get(name) { + Some(partial) => { + let partial_nodes = match parsed_partials.get(name) { + Some(nodes) => nodes + None => { + let parsed = parse_template(partial) + parsed_partials[name] = parsed + parsed + } + } + out = out + + render_nodes(partial_nodes, stack, partials, parsed_partials) + } + None => () + } + } + } + out +} + +///| +fn split_dotted(name : String) -> Array[String] { + let parts : Array[String] = [] + let mut part : Array[Char] = [] + for c in name { + if c == '.' { + parts.push(String::from_array(part)) + part = [] + } else { + part.push(c) + } + } + parts.push(String::from_array(part)) + parts +} + +///| +fn lookup(stack : Array[Json], name : String) -> Json? { + if name == "." { + if stack.is_empty() { + None + } else { + stack.get(stack.length() - 1) + } + } else { + let parts = split_dotted(name) + match parts.get(0) { + Some(first) => { + let mut current = resolve_in_stack(stack, first) + let mut i = 1 + while i < parts.length() { + current = match (current, parts.get(i)) { + (Some(Object(obj)), Some(part)) => obj.get(part) + _ => None + } + i += 1 + } + current + } + None => None + } + } +} + +///| +fn resolve_in_stack(stack : Array[Json], key : String) -> Json? { + let mut i = stack.length() + while i > 0 { + i -= 1 + match stack.get(i) { + Some(Object(obj)) => + match obj.get(key) { + Some(value) => return Some(value) + None => () + } + _ => () + } + } + None +} + +///| +fn is_truthy(value : Json) -> Bool { + match value { + Null | False => false + Array(items) => items.length() > 0 + _ => true + } +} + +///| +fn is_falsey(value : Json?) -> Bool { + match value { + None => true + Some(v) => !is_truthy(v) + } +} + +///| +fn stringify_value(value : Json) -> String { + match value { + Null => "" + String(text) => text + _ => value.stringify() + } +} + +///| +fn escape_html(value : String) -> String { + value + .replace_all(old="&", new="&") + .replace_all(old="\"", new=""") + .replace_all(old="'", new="'") + .replace_all(old="<", new="<") + .replace_all(old=">", new=">") +} diff --git a/src/mustache/moon.pkg b/src/mustache/moon.pkg new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/mustache/moon.pkg @@ -0,0 +1 @@ + diff --git a/src/mustache/mustache_test.mbt b/src/mustache/mustache_test.mbt new file mode 100644 index 0000000..54b9fe7 --- /dev/null +++ b/src/mustache/mustache_test.mbt @@ -0,0 +1,165 @@ +///| +fn obj(entries : Map[String, Json]) -> Json { + Json::object(entries) +} + +///| +fn arr(items : Array[Json]) -> Json { + Json::array(items) +} + +///| +fn num(value : Double) -> Json { + Json::number(value) +} + +///| +fn str(value : String) -> Json { + Json::string(value) +} + +///| +fn bool(value : Bool) -> Json { + Json::boolean(value) +} + +///| +fn run(template : String, data : Json, expected : String) -> Unit raise Failure { + let actual = @mustache.render(template, data) + if actual != expected { + fail("expected: " + expected + "\nactual: " + actual) + } +} + +///| +fn run_with_partials( + template : String, + data : Json, + expected : String, + partials : Map[String, String], +) -> Unit raise Failure { + let actual = @mustache.render(template, data, partials~) + if actual != expected { + fail("expected: " + expected + "\nactual: " + actual) + } +} + +///| +test "official interpolation spec cases" { + // Source: https://github.com/mustache/spec/blob/master/specs/interpolation.json + run( + "Hello, {{subject}}!\n", + obj({ "subject": str("world") }), + "Hello, world!\n", + ) + run( + "These characters should be HTML escaped: {{forbidden}}\n", + obj({ "forbidden": str("& \" < >") }), + "These characters should be HTML escaped: & " < >\n", + ) + run( + "These characters should not be HTML escaped: {{{forbidden}}}\n", + obj({ "forbidden": str("& \" < >") }), + "These characters should not be HTML escaped: & \" < >\n", + ) + run( + "\"{{person.name}}\" == \"{{#person}}{{name}}{{/person}}\"", + obj({ "person": obj({ "name": str("Joe") }) }), + "\"Joe\" == \"Joe\"", + ) + run( + "I ({{cannot}}) be seen!", + obj({ "cannot": Json::null() }), + "I () be seen!", + ) +} + +///| +test "official sections spec cases" { + // Source: https://github.com/mustache/spec/blob/master/specs/sections.json + run( + "\"{{#boolean}}This should be rendered.{{/boolean}}\"", + obj({ "boolean": bool(true) }), + "\"This should be rendered.\"", + ) + run( + "\"{{#boolean}}This should not be rendered.{{/boolean}}\"", + obj({ "boolean": bool(false) }), + "\"\"", + ) + run( + "\"{{#context}}Hi {{name}}.{{/context}}\"", + obj({ "context": obj({ "name": str("Joe") }) }), + "\"Hi Joe.\"", + ) + run( + "\"{{#list}}{{item}}{{/list}}\"", + obj({ + "list": arr([ + obj({ "item": num(1) }), + obj({ "item": num(2) }), + obj({ "item": num(3) }), + ]), + }), + "\"123\"", + ) + run( + "\"{{#list}}({{.}}){{/list}}\"", + obj({ "list": arr([str("a"), str("b"), str("c")]) }), + "\"(a)(b)(c)\"", + ) +} + +///| +test "official inverted spec cases" { + // Source: https://github.com/mustache/spec/blob/master/specs/inverted.json + run( + "\"{{^boolean}}This should be rendered.{{/boolean}}\"", + obj({ "boolean": bool(false) }), + "\"This should be rendered.\"", + ) + run( + "\"{{^boolean}}This should not be rendered.{{/boolean}}\"", + obj({ "boolean": bool(true) }), + "\"\"", + ) + run( + "\"{{^list}}Yay lists!{{/list}}\"", + obj({ "list": arr([]) }), + "\"Yay lists!\"", + ) +} + +///| +test "official comments and delimiters spec cases" { + // Sources: + // - https://github.com/mustache/spec/blob/master/specs/comments.json + // - https://github.com/mustache/spec/blob/master/specs/delimiters.json + run("12345{{! Comment Block! }}67890", obj({}), "1234567890") + run( + "12345{{!\n This is a\n multi-line comment...\n}}67890\n", + obj({}), + "1234567890\n", + ) + run("{{=<% %>=}}(<%text%>)", obj({ "text": str("Hey!") }), "(Hey!)") +} + +///| +test "official partials spec cases" { + // Source: https://github.com/mustache/spec/blob/master/specs/partials.json + run_with_partials("\"{{>text}}\"", obj({}), "\"from partial\"", { + "text": "from partial", + }) + run_with_partials( + "\"{{>partial}}\"", + obj({ "text": str("content") }), + "\"*content*\"", + { "partial": "*{{text}}*" }, + ) + run_with_partials( + "{{>outer}}", + obj({ "a": str("hello"), "b": str("world") }), + "*hello world!*", + { "outer": "*{{a}} {{>inner}}*", "inner": "{{b}}!" }, + ) +}