aboutsummaryrefslogtreecommitdiff
path: root/lib/render
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 08:01:11 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-08-19 08:01:11 +0200
commiteacb54ae44f655c010fdfc2b7292d24f06ffc445 (patch)
treefdcae49d2be2630b559a3b21a131cf4194feeb5e /lib/render
parent7228d0634a960c38da96b80378ec617768dfedf7 (diff)
downloadcolitur-eacb54ae44f655c010fdfc2b7292d24f06ffc445.tar.gz
colitur-eacb54ae44f655c010fdfc2b7292d24f06ffc445.zip
feat(render): logic-less template parser
Placeholders, sections, inverted sections, comments. Nothing else: no partials, no lambdas, no expression evaluation, no raw form. A template is data, never a program, which is what keeps an untrusted template safe. Errors rather than silence on a malformed template: an unterminated tag, an unclosed section, a mismatched close and a partial all return Error. Swallowing '{{name' as text is how a typo becomes invisible missing output in a printed booklet.
Diffstat (limited to 'lib/render')
-rw-r--r--lib/render/template.ml86
-rw-r--r--lib/render/template.mli28
2 files changed, 114 insertions, 0 deletions
diff --git a/lib/render/template.ml b/lib/render/template.ml
new file mode 100644
index 0000000..8de91a6
--- /dev/null
+++ b/lib/render/template.ml
@@ -0,0 +1,86 @@
+type value =
+ | Str of string
+ | Bool of bool
+ | List of value list
+ | Obj of (string * value) list
+
+type node =
+ | Text of string
+ | Var of string list
+ | Section of string list * node list
+ | Inverted of string list * node list
+
+type tag = TVar of string list | TOpen of string list | TInv of string list | TClose of string list | TComment
+
+let trim = String.trim
+
+let path s =
+ String.split_on_char '.' (trim s) |> List.map trim |> List.filter (fun x -> x <> "")
+
+(* Lex into a flat list of text and tags. Returns Error on an unterminated tag
+ rather than treating the remainder as text: silently swallowing "{{name" is
+ how a typo becomes invisible missing output. *)
+let lex src =
+ let n = String.length src in
+ let out = ref [] in
+ let buf = Buffer.create 256 in
+ let flush () =
+ if Buffer.length buf > 0 then (out := `Text (Buffer.contents buf) :: !out; Buffer.clear buf)
+ in
+ let rec go i =
+ if i >= n then (flush (); Ok (List.rev !out))
+ else if i + 1 < n && src.[i] = '{' && src.[i + 1] = '{' then
+ match String.index_from_opt src (i + 2) '}' with
+ | Some j when j + 1 < n && src.[j + 1] = '}' ->
+ let body = String.sub src (i + 2) (j - i - 2) in
+ flush ();
+ let tag =
+ let b = trim body in
+ if b = "" then Error "empty tag {{}}"
+ else
+ match b.[0] with
+ | '#' -> Ok (TOpen (path (String.sub b 1 (String.length b - 1))))
+ | '^' -> Ok (TInv (path (String.sub b 1 (String.length b - 1))))
+ | '/' -> Ok (TClose (path (String.sub b 1 (String.length b - 1))))
+ | '!' -> Ok TComment
+ | '>' -> Error "partials are not supported: a template may not include another file"
+ | _ -> Ok (TVar (path b))
+ in
+ (match tag with
+ | Error e -> Error e
+ | Ok t -> out := `Tag t :: !out; go (j + 2))
+ | _ -> Error "unterminated tag: '{{' with no matching '}}'"
+ else (Buffer.add_char buf src.[i]; go (i + 1))
+ in
+ go 0
+
+(* Fold the flat list into a tree, checking that every close matches its open. *)
+let build items =
+ let rec go acc stack items =
+ match items with
+ | [] -> (
+ match stack with
+ | [] -> Ok (List.rev acc)
+ | (p, _, _) :: _ -> Error ("unclosed section {{#" ^ String.concat "." p ^ "}}"))
+ | `Text "" :: rest -> go acc stack rest
+ | `Text t :: rest -> go (Text t :: acc) stack rest
+ | `Tag TComment :: rest -> go acc stack rest
+ | `Tag (TVar p) :: rest -> go (Var p :: acc) stack rest
+ | `Tag (TOpen p) :: rest -> go [] ((p, `Sec, acc) :: stack) rest
+ | `Tag (TInv p) :: rest -> go [] ((p, `Inv, acc) :: stack) rest
+ | `Tag (TClose p) :: rest -> (
+ match stack with
+ | [] -> Error ("close tag {{/" ^ String.concat "." p ^ "}} with no open section")
+ | (op, kind, outer) :: tl ->
+ if op <> p then
+ Error
+ ("mismatched close: {{#" ^ String.concat "." op ^ "}} closed by {{/"
+ ^ String.concat "." p ^ "}}")
+ else
+ let inner = List.rev acc in
+ let node = match kind with `Sec -> Section (op, inner) | `Inv -> Inverted (op, inner) in
+ go (node :: outer) tl rest)
+ in
+ go [] [] items
+
+let parse src = match lex src with Error e -> Error e | Ok items -> build items
diff --git a/lib/render/template.mli b/lib/render/template.mli
new file mode 100644
index 0000000..3f9ef97
--- /dev/null
+++ b/lib/render/template.mli
@@ -0,0 +1,28 @@
+(** A deliberately logic-less template engine (spec section 5).
+
+ A template is DATA, never a program: placeholders, sections, inverted
+ sections and comments, and nothing else. There are no partials, no lambdas,
+ no expression evaluation, no arithmetic, and no filesystem or process
+ access. There is deliberately no "raw" or triple-brace form -- a template
+ cannot opt out of its flavour's escaping.
+
+ Anything a calendar needs that this cannot express (a month grid's leading
+ blanks, week bucketing) is computed in {!View} and handed in as data. That
+ division is the design, not a workaround. *)
+
+(** What a template can be rendered against. *)
+type value =
+ | Str of string
+ | Bool of bool
+ | List of value list
+ | Obj of (string * value) list
+
+type node =
+ | Text of string
+ | Var of string list (** dotted path, e.g. ["name"; "la"] *)
+ | Section of string list * node list
+ | Inverted of string list * node list
+
+(** Never raises; a malformed template is an [Error] with a human-readable
+ reason, because the template is user input. *)
+val parse : string -> (node list, string) result