From 6a137c5638648fb5e7b9a13c46c86fe7c7fdaa95 Mon Sep 17 00:00:00 2001 From: Isaac Van Doren <69181572+isaacvando@users.noreply.github.com> Date: Thu, 21 Mar 2024 18:26:57 -0500 Subject: [PATCH 1/3] add placeholder for template --- template/index.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 template/index.md diff --git a/template/index.md b/template/index.md new file mode 100644 index 0000000..7e2a579 --- /dev/null +++ b/template/index.md @@ -0,0 +1 @@ +# Template Engine From 25604c12c661fe9e1591afd4ac392fc85b87f703 Mon Sep 17 00:00:00 2001 From: Isaac Van Doren <69181572+isaacvando@users.noreply.github.com> Date: Wed, 27 Mar 2024 21:18:22 -0400 Subject: [PATCH 2/3] add template draft --- template/.gitignore | 5 + template/CodeGen.roc | 127 +++++++++++ template/LICENSE.md | 17 ++ template/Parser.roc | 382 ++++++++++++++++++++++++++++++++++ template/README.md | 98 +++++++++ template/compile.roc | 72 +++++++ template/example/Pages.roc | 67 ++++++ template/example/blogPost.rtl | 14 ++ template/example/home.rtl | 19 ++ template/example/server.roc | 60 ++++++ template/index.md | 36 +++- 11 files changed, 896 insertions(+), 1 deletion(-) create mode 100644 template/.gitignore create mode 100644 template/CodeGen.roc create mode 100644 template/LICENSE.md create mode 100644 template/Parser.roc create mode 100644 template/README.md create mode 100644 template/compile.roc create mode 100644 template/example/Pages.roc create mode 100644 template/example/blogPost.rtl create mode 100644 template/example/home.rtl create mode 100644 template/example/server.roc diff --git a/template/.gitignore b/template/.gitignore new file mode 100644 index 0000000..77a422c --- /dev/null +++ b/template/.gitignore @@ -0,0 +1,5 @@ +server +engine +output +compile +output.roc diff --git a/template/CodeGen.roc b/template/CodeGen.roc new file mode 100644 index 0000000..4385b5d --- /dev/null +++ b/template/CodeGen.roc @@ -0,0 +1,127 @@ +interface CodeGen + exposes [generate] + imports [Parser.{ Node }] + +generate : List { name : Str, nodes : List Node } -> Str +generate = \templates -> + functions = + List.map templates renderTemplate + |> Str.joinWith "\n\n" + names = + List.map templates .name + |> Str.joinWith ",\n" + |> indent + |> indent + + """ + interface Pages + exposes [ + $(names) + ] + imports [] + + $(functions) + + escapeHtml : Str -> Str + escapeHtml = \\input -> + input + |> Str.replaceEach "&" "&" + |> Str.replaceEach "<" "<" + |> Str.replaceEach ">" ">" + |> Str.replaceEach "\\"" """ + |> Str.replaceEach "'" "'" + + """ + +# \"" + +RenderNode : [ + Text Str, + Conditional { condition : Str, trueBranch : List RenderNode, falseBranch : List RenderNode }, + Sequence { item : Str, list : Str, body : List RenderNode }, +] + +renderTemplate : { name : Str, nodes : List Node } -> Str +renderTemplate = \{ name, nodes } -> + body = + condense nodes + |> renderNodes + + """ + $(name) = \\model -> + $(body) + """ + +renderNodes : List RenderNode -> Str +renderNodes = \nodes -> + when List.map nodes toStr is + [] -> "\"\"" |> indent + [elem] -> elem + blocks -> + list = blocks |> Str.joinWith ",\n" + """ + [ + $(list) + ] + |> Str.joinWith "" + """ + |> indent + +toStr = \node -> + block = + when node is + Text t -> + """ + \""" + $(t) + \""" + """ + + Conditional { condition, trueBranch, falseBranch } -> + """ + if $(condition) then + $(renderNodes trueBranch) + else + $(renderNodes falseBranch) + """ + + Sequence { item, list, body } -> + """ + List.map $(list) \\$(item) -> + $(renderNodes body) + |> Str.joinWith "" + """ + indent block + +condense : List Node -> List RenderNode +condense = \nodes -> + List.map nodes \node -> + when node is + RawInterpolation i -> Text "\$($(i))" + Interpolation i -> Text "\$($(i) |> escapeHtml)" + Text t -> + # Escape Roc string interpolations from the template + escaped = Str.replaceEach t "$" "\\$" + Text escaped + + Sequence { item, list, body } -> Sequence { item, list, body: condense body } + Conditional { condition, trueBranch, falseBranch } -> + Conditional { + condition, + trueBranch: condense trueBranch, + falseBranch: condense falseBranch, + } + |> List.walk [] \state, elem -> + when (state, elem) is + ([.. as rest, Text x], Text y) -> + combined = Str.concat x y |> Text + rest |> List.append combined + + _ -> List.append state elem + +indent : Str -> Str +indent = \input -> + Str.split input "\n" + |> List.map \str -> + Str.concat " " str + |> Str.joinWith "\n" diff --git a/template/LICENSE.md b/template/LICENSE.md new file mode 100644 index 0000000..5493086 --- /dev/null +++ b/template/LICENSE.md @@ -0,0 +1,17 @@ +Copyright (c) 2024 Isaac Van Doren and contributors + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any person obtaining a copy of this software, associated documentation and/or data (collectively the “Software”), free of charge and under any and all copyright rights in the Software, and any and all patent rights owned or freely licensable by each licensor hereunder covering either (i) the unmodified Software as contributed to or provided by such licensor, or (ii) the Larger Works (as defined below), to deal in both + +(a) the Software, and + +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if one is included with the Software (each a “Larger Work” to which the Software is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create derivative works of, display, perform, and distribute the Software and make, use, sell, offer for sale, import, export, have made, and have sold the Software and the Larger Work(s), and to sublicense the foregoing rights on either these or other terms. + +This license is subject to the following condition: + +The above copyright notice and either this complete permission notice or at a minimum a reference to the UPL must 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. \ No newline at end of file diff --git a/template/Parser.roc b/template/Parser.roc new file mode 100644 index 0000000..b6e9940 --- /dev/null +++ b/template/Parser.roc @@ -0,0 +1,382 @@ +interface Parser + exposes [parse, Node] + imports [] + +Node : [ + Text Str, + Interpolation Str, + RawInterpolation Str, + Conditional { condition : Str, trueBranch : List Node, falseBranch : List Node }, + Sequence { item : Str, list : Str, body : List Node }, +] + +parse : Str -> List Node +parse = \input -> + when Str.toUtf8 input |> (many node) is + Match { input: [], val } -> combineTextNodes val + Match _ -> crash "There is a bug! Not all input was consumed." + NoMatch -> crash "There is a bug! The parser failed." + +combineTextNodes : List Node -> List Node +combineTextNodes = \nodes -> + List.walk nodes [] \state, elem -> + when (state, elem) is + ([.. as rest, Text t1], Text t2) -> + List.append rest (Text (Str.concat t1 t2)) + + (_, Conditional { condition, trueBranch, falseBranch }) -> + List.append state (Conditional { condition, trueBranch: combineTextNodes trueBranch, falseBranch: combineTextNodes falseBranch }) + + (_, Sequence { item, list, body }) -> + List.append state (Sequence { item, list, body: combineTextNodes body }) + + _ -> List.append state elem + +Parser a : List U8 -> [Match { input : List U8, val : a }, NoMatch] + +# Parsers + +node = + oneOf [ + rawInterpolation, + interpolation, + conditionalElse, + conditionalIf, + sequence, + text, + ] + +interpolation : Parser Node +interpolation = + manyUntil anyByte (string "}}") + |> startWith (string "{{") + |> map \bytes -> + unsafeFromUtf8 bytes + |> Str.trim + |> Interpolation + +rawInterpolation : Parser Node +rawInterpolation = + manyUntil anyByte (string "}}}") + |> startWith (string "{{{") + |> map \bytes -> + unsafeFromUtf8 bytes + |> Str.trim + |> RawInterpolation + +conditionalIf = + condition <- manyUntil anyByte (string " |}") + |> startWith (string "{|if ") + |> leftoverTagSpace + |> andThen + + endIf = + string "{|endif|}" + |> startWith (optional (string "\n")) + |> leftoverTagSpace + + trueBranch <- manyUntil node endIf + |> andThen + + val = Conditional { + condition: unsafeFromUtf8 condition, + trueBranch, + falseBranch: [], + } + + \input -> Match { input, val } + +conditionalElse = + condition <- manyUntil anyByte (string " |}") + |> startWith (string "{|if ") + |> leftoverTagSpace + |> andThen + + elseSep = + string "{|else|}" + |> startWith (optional (string "\n")) + |> leftoverTagSpace + + endIf = + string "{|endif|}" + |> startWith (optional (string "\n")) + |> leftoverTagSpace + + trueBranch <- manyUntil node elseSep + |> andThen + + falseBranch <- manyUntil node endIf + |> andThen + + val = Conditional { + condition: unsafeFromUtf8 condition, + trueBranch, + falseBranch, + } + + \input -> Match { input, val } + +sequence : Parser Node +sequence = + item <- manyUntil anyByte (string " : ") + |> startWith (string "{|list ") + |> andThen + + list <- + manyUntil anyByte (string " |}") + |> leftoverTagSpace + |> andThen + + endList = + string "{|endlist|}" + |> leftoverTagSpace + + body <- manyUntil node endList + |> andThen + + val = Sequence { + item: unsafeFromUtf8 item, + list: unsafeFromUtf8 list, + body: body, + } + + \input -> Match { input, val } + +leftoverTagSpace : Parser a -> Parser a +leftoverTagSpace = \parser -> + parser + |> endWith (optional (string "\n")) + |> endWith (many hSpace) + +text : Parser Node +text = + anyByte + |> map \byte -> + unsafeFromUtf8 [byte] + |> Text + +hSpace : Parser Str +hSpace = + oneOf [string " ", string "\t"] + +string : Str -> Parser Str +string = \str -> + \input -> + bytes = Str.toUtf8 str + if List.startsWith input bytes then + Match { input: List.dropFirst input (List.len bytes), val: str } + else + NoMatch + +anyByte : Parser U8 +anyByte = \input -> + when input is + [first, .. as rest] -> Match { input: rest, val: first } + _ -> NoMatch + +# Combinators + +startWith : Parser a, Parser * -> Parser a +startWith = \parser, start -> + andThen start \_ -> + parser + +endWith : Parser a, Parser * -> Parser a +endWith = \parser, end -> + andThen parser \m -> + end |> map \_ -> m + +oneOf : List (Parser a) -> Parser a +oneOf = \options -> + when options is + [] -> \_ -> NoMatch + [first, .. as rest] -> + \input -> + when first input is + Match m -> Match m + NoMatch -> (oneOf rest) input + +many : Parser a -> Parser (List a) +many = \parser -> + help = \input, items -> + when parser input is + NoMatch -> Match { input: input, val: items } + Match m -> help m.input (List.append items m.val) + + \input -> help input [] + +optional = \parser -> + \input -> + when parser input is + NoMatch -> Match { input, val: {} } + Match m -> Match { input: m.input, val: {} } + +manyUntil : Parser a, Parser * -> Parser (List a) +manyUntil = \parser, end -> + help = \input, items -> + when end input is + Match state -> Match { input: state.input, val: items } + NoMatch -> + when parser input is + NoMatch -> NoMatch + Match m -> help m.input (List.append items m.val) + + \input -> help input [] + +andThen : Parser a, (a -> Parser b) -> Parser b +andThen = \parser, mapper -> + \input -> + when parser input is + NoMatch -> NoMatch + Match m -> (mapper m.val) m.input + +map : Parser a, (a -> b) -> Parser b +map = \parser, mapper -> + \in -> + when parser in is + Match { input, val } -> Match { input, val: mapper val } + NoMatch -> NoMatch + +unsafeFromUtf8 = \bytes -> + when Str.fromUtf8 bytes is + Ok s -> s + Err _ -> + crash "I was unable to convert these bytes into a string: $(Inspect.toStr bytes)" + +# Tests + +expect + result = parse "foo" + result == [Text "foo"] + +expect + result = parse "
{{name}}
" + result == [Text "", Interpolation "name", Text "
"] + +expect + result = parse "{{foo}bar}}" + result == [Interpolation "foo}bar"] + +expect + result = parse "{{{raw val}}}" + result == [RawInterpolation "raw val"] + +expect + result = parse "{{{ foo : 10 } |> \\x -> Num.toStr x.foo}}" + result == [Interpolation "{ foo : 10 } |> \\x -> Num.toStr x.foo"] + +expect + result = parse "{{func arg1 arg2 |> func2 arg2}}" + result == [Interpolation "func arg1 arg2 |> func2 arg2"] + +expect + result = parse "{|if x > y |}foo{|endif|}" + result == [Conditional { condition: "x > y", trueBranch: [Text "foo"], falseBranch: [] }] + +expect + result = parse + """ + {|if x > y |} + foo + {|endif|} + """ + result == [Conditional { condition: "x > y", trueBranch: [Text "foo"], falseBranch: [] }] + +expect + result = parse + """ + {|if model.field |} + Hello + {|else|} + goodbye + {|endif|} + """ + result + == [ + Conditional { + condition: "model.field", + trueBranch: [Text "Hello"], + falseBranch: [Text "goodbye"], + }, + ] + +expect + result = parse + """ + {|if model.someField |} + {|if Bool.false |} + bar + {|endif|} + {|endif|} + """ + result + == [ + Conditional { + condition: "model.someField", + trueBranch: [ + Conditional { condition: "Bool.false", trueBranch: [Text "bar"], falseBranch: [] }, + ], + falseBranch: [], + }, + ] + +expect + result = parse + """ + foo + bar + {{model.baz}} + foo + """ + result == [Text "foo\nbar\n", Interpolation "model.baz", Text "\nfoo"] + +expect + result = parse + """ ++ {|if foo |} + bar + {|endif|} +
+ """ + result + == [ + Text "\n ", + Conditional { condition: "foo", trueBranch: [Text "bar\n "], falseBranch: [] }, + Text "
", + ] + +expect + result = parse + """ +Hello {{user}}!
+ {|endlist|} + """ + + result + == + [ + Sequence { + item: "user", + list: "users", + body: [ + Text "Hello ", + Interpolation "user", + Text "!
\n", + ], + }, + ] diff --git a/template/README.md b/template/README.md new file mode 100644 index 0000000..e796760 --- /dev/null +++ b/template/README.md @@ -0,0 +1,98 @@ +# roc-template +An HTML template language for Roc. + +## Example + +First write a template like `hello.rtl`: +``` +Hello, {{model.name}}!
+ +Hello, Isaac!
+ +{{ paragraph }}
+{|endlist|} +``` + +The pattern can be any normal Roc pattern, so things like this are also valid: +``` +{|list (x,y) : [(1,2),(3,4)] |} +X: {{ x |> Num.toStr }}, Y: {{ y |> Num.toStr }}
+{|endlist|} +``` + +### Conditionals +Conditionally include content like this: +``` +{|if model.x < model.y |} +Conditional content here +{|endif|} +``` +Or with an else block: +``` +{|if model.x < model.y |} +Conditional content here +{|else|} +Other content +{|endif|} +``` + +### Raw Interpolation +If it is necessary to insert content into the document without escaping HTML, use triple brackets. +``` +{{{ model.dynamicHtml }}} +``` diff --git a/template/compile.roc b/template/compile.roc new file mode 100644 index 0000000..df62e89 --- /dev/null +++ b/template/compile.roc @@ -0,0 +1,72 @@ +app "compile" + packages { + pf: "https://github.com/roc-lang/basic-cli/releases/download/0.8.1/x8URkvfyi9I0QhmVG98roKBUs_AZRkLFwFJVJ3942YA.tar.br", + } + imports [ + pf.Stdout, + pf.Task.{ Task }, + pf.Path.{ Path }, + pf.File, + pf.Dir, + Parser, + CodeGen, + ] + provides [main] to pf + +main = + paths <- Dir.list (Path.fromStr ".") + |> Task.onErr \e -> + {} <- Stdout.line "Error listing directories: $(Inspect.toStr e)" |> Task.await + Task.err 1 + |> Task.map keepTemplates + |> Task.await + + templates <- taskAll paths \path -> + File.readUtf8 path + |> Task.map \template -> + { path, template } + |> Task.onErr \e -> + {} <- Stdout.line "There was an error reading the templates: $(Inspect.toStr e)" |> Task.await + Task.err 1 + |> Task.await + + {} <- File.writeUtf8 (Path.fromStr "Pages.roc") (compile templates) + |> Task.onErr \e -> + {} <- Stdout.line "Error writing file: $(Inspect.toStr e)" |> Task.await + Task.err 1 + |> Task.await + + Stdout.line "Generated Pages.roc" + +taskAll : List a, (a -> Task b err) -> Task (List b) err +taskAll = \items, task -> + Task.loop { vals: [], rest: items } \{ vals, rest } -> + when rest is + [] -> Done vals |> Task.ok + [item, .. as remaining] -> + Task.map (task item) \val -> + Step { vals: List.append vals val, rest: remaining } + +keepTemplates : List Path -> List Path +keepTemplates = \paths -> + List.keepIf paths \p -> + Path.display p + |> Str.endsWith extension + +compile : List { path : Path, template : Str } -> Str +compile = \templates -> + templates + |> List.map \{ path, template } -> + { name: extractFunctionName path, nodes: Parser.parse template } + |> CodeGen.generate + +extractFunctionName : Path -> Str +extractFunctionName = \path -> + display = Path.display path + when Str.split display "/" is + [.., filename] if Str.endsWith filename extension -> + Str.replaceLast filename extension "" + + _ -> crash "Error: $(display) is not a valid template path" + +extension = ".rtl" diff --git a/template/example/Pages.roc b/template/example/Pages.roc new file mode 100644 index 0000000..6c7452a --- /dev/null +++ b/template/example/Pages.roc @@ -0,0 +1,67 @@ +interface Pages + exposes [ + blogPost, + home + ] + imports [] + +blogPost = \model -> + """ + + + + +