Templates¶
(kaappi template) — text and HTML template engine with variables,
conditionals, loops, and auto-escaping.
Pure Scheme — no dependencies.
Quick start¶
(import (kaappi template))
;; Plain text
(template-render "Hello, {{.name}}!" '(("name" . "Alice")))
;=> "Hello, Alice!"
;; HTML (auto-escapes <>&"')
(template-render-html "<p>{{.content}}</p>"
'(("content" . "<script>alert('xss')</script>")))
;=> "<p><script>alert('xss')</script></p>"
Template syntax¶
Variables¶
{{.name}} key from the data alist
{{.user.email}} nested dotted path
{{.}} current value (inside range loops)
Undefined keys render as empty strings.
Conditionals¶
{{if .logged_in}}Welcome back!{{end}}
{{if .logged_in}}
Welcome, {{.username}}!
{{else}}
Please log in.
{{end}}
Falsy values: #f, 'null, "", (), 0. Everything else is truthy.
Loops¶
Inside range, {{.}} refers to the current item. Use {{.field}} to
access fields when items are objects (alists).
Loop over a flat list¶
(template-render "{{range .names}}Hello {{.}}! {{end}}"
'(("names" . ("Alice" "Bob" "Carol"))))
;=> "Hello Alice! Hello Bob! Hello Carol! "
Loop over objects¶
(template-render "{{range .users}}{{.name}} ({{.role}})\n{{end}}"
'(("users" . ((("name" . "Alice") ("role" . "admin"))
(("name" . "Bob") ("role" . "user"))))))
;=> "Alice (admin)\nBob (user)\n"
HTML escaping¶
template-render-html escapes these characters in all variable output:
| Character | Escape |
|---|---|
< |
< |
> |
> |
& |
& |
" |
" |
' |
' |
This prevents XSS attacks. Use template-render (plain text) only when
the output is not HTML.
Data format¶
Data is passed as an alist. Nested data uses nested alists:
(define data
'(("user" . (("name" . "Alice")
("email" . "alice@example.com")))
("items" . ((("name" . "Widget") ("price" . 9.99))
(("name" . "Gadget") ("price" . 24.50))))
("show_footer" . #t)))
Template:
{{.user.name}} ({{.user.email}})
Items:
{{range .items}}
- {{.name}}: ${{.price}}
{{end}}
{{if .show_footer}}Generated by Kaappi{{end}}
Pre-parsing for performance¶
Parse a template once, render many times:
(define tmpl (template-parse "Hello, {{.name}}!"))
(template-execute tmpl '(("name" . "Alice")) values)
;=> "Hello, Alice!"
(template-execute tmpl '(("name" . "Bob")) values)
;=> "Hello, Bob!"
The third argument to template-execute is the escape function:
- values — no escaping (identity function)
- html-escape — HTML entity escaping
Performance tip¶
For templates rendered on every HTTP request, parse once at startup:
(define page-ast (template-parse page-template))
(GET "/"
(lambda (req params)
(html-response
(template-execute page-ast data html-escape))))
HTML escaping utility¶
The html-escape function is available independently:
(html-escape "<script>alert('xss')</script>")
;=> "<script>alert('xss')</script>"
(html-escape "safe text")
;=> "safe text"
Common patterns¶
Layout with content blocks¶
(define layout "<html><head><title>{{.title}}</title></head>
<body><nav>{{.nav}}</nav>{{.body}}</body></html>")
(define (render-page title body-html)
(template-render layout
`(("title" . ,(html-escape title))
("nav" . "<a href='/'>Home</a>")
("body" . ,body-html))))
The layout is rendered with template-render (no auto-escaping) because
nav and body-html are markup that must land verbatim; plain data
values like the title are escaped explicitly with html-escape. Render
the body fragments themselves with template-render-html so their data
stays auto-escaped.
Conditional CSS classes¶
Empty state handling¶
{{if .items}}
<ul>
{{range .items}}<li>{{.name}}</li>{{end}}
</ul>
{{else}}
<p>No items found.</p>
{{end}}
API reference¶
| Procedure | Description |
|---|---|
(template-render tmpl data) |
Render with no escaping |
(template-render-html tmpl data) |
Render with HTML escaping |
(template-parse tmpl) |
Parse to reusable AST |
(template-execute ast data escape-fn) |
Execute AST with custom escaping |
(html-escape str) |
Escape HTML entities |