From a first page to a language of your own
Each section builds on the one before. Every example runs as written; the nine programs in src/examples put them together, from a one-line render to a small application.
Quick start
moon add moonbitstack/moonpug
Add the package to the moon.pkg that uses it, then render:
let out = @moonpug.render("Hello {{ name|title }}!", { "name": "ada" }) // Hello Ada!
render compiles and renders in one call. When the same template renders many times, compile it once:
let env = @moonpug.Env::new() let greet = env.compile("Hello {{ name }}!") greet.render({ "name": "Ada" }) greet.render({ "name": "Grace" })
Data in
A context is anything that converts to Json: a #derive(ToJson) struct, a map literal, a parsed document. Objects become mappings, arrays become lists, and whole numbers become integers.
#derive(ToJson) struct Book { title : String; year : Int; tags : Array[String] } #derive(ToJson) struct Shelf { shelf : String; books : Array[Book] } @moonpug.render(page, Shelf::{ shelf: "Reading", books })
Values that are more than data, such as functions and your own objects, go in through render_values with a Map[String, Value]; see host objects.
Languages
| Name | Also | Reads | Preset |
|---|---|---|---|
html | text with {{ }}, {% %}, {# #} | html escaped, text not | |
pug | pg | elements by indentation | pug |
md | markdown | braces, then Markdown | markdown |
@moonpug.render(source, data, format=@moonpug.pug) @moonpug.render(source, data, format=@moonpug.markdown)
All three write HTML5: a template is what you write, the page is what comes out. The md language is the brace syntax plus one more step, described below.
An unnamed source reads as settings.format, escaped HTML unless you change it. A named template takes its format from settings.formats, which by default goes by extension: .pug and .pg as pug, .md and .markdown as Markdown, .html, .htm, .xml, .svg and .xhtml as escaped HTML, anything else as unescaped text.
Output and escaping
{{ expression }} writes a value. With escaping on, & < > " ' become entities. A value marked safe passes through:
{{ title }} <!-- <b>x</b> -->
{{ title|safe }} <!-- <b>x</b> -->
{% autoescape false %}{{ title }}{% endautoescape %}
Filters that build markup, such as urlize, xmlattr, linebreaks and json_script, escape what they wrap and return safe markup. {# … #} is a comment.
Statements
{% if user %}Hello {{ user.name }}{% elif guest %}Welcome{% else %}Sign in{% endif %}
{% for item in items if item.visible %}
{{ loop.index }}/{{ loop.length }} {{ item.name }}{% if not loop.last %},{% endif %}
{% else %}
Nothing yet.
{% endfor %}
{% set total = items|sum(attribute='price') %}
{% set ns = namespace(count=0) %}{% for i in items %}{% set ns.count = ns.count + 1 %}{% endfor %}
{% macro field(name, value='', type='text') %}
<input name="{{ name }}" value="{{ value }}" type="{{ type }}">
{% endmacro %}
{{ field('email', type='email') }}
{% call field_row('Name') %}{{ field('name') }}{% endcall %}
{% filter upper %}shouted{% endfilter %}
{% with total = 3 %}{{ total }}{% endwith %}
Inside a loop, loop has index, index0, revindex, first, last, length, depth, previtem, nextitem, cycle(…) and changed(…); a recursive loop calls loop(children). break, continue and do are there too.
Expressions
{{ 7 // 2 }} {{ 7 % 3 }} {{ 2 ** 10 }} {{ 1 < x <= 9 }} {{ 'a' in name }}
{{ first ~ ' ' ~ last }} {{ 'yes' if ok else 'no' }} {{ items[1:3] }}
{{ 'a,b'.split(',') }} {{ '{:>8.2f}'.format(price) }} {{ '%s=%d' % (key, n) }}
{{ user.address.city }} {{ user['first-name'] }} {{ data.items()|list }}
Strings, lists and maps carry their methods. Numbers are 64-bit integers and doubles; integer arithmetic that would overflow is refused rather than wrapped.
Filters and tests
{{ name|default('anonymous')|title }}
{{ posts|selectattr('published')|sort(attribute='date', reverse=true)|map(attribute='title')|join(', ') }}
{{ created|date('N j, Y') }} {{ created|timesince(now) }} {{ price|floatformat(2) }}
{{ n }} item{{ n|pluralize }} {{ value is number }} {{ 6 is divisibleby(3) }}
The full list of built-in filters, tests and globals is on the home page, read from the engine itself.
Whitespace
A - inside a tag strips the whitespace on that side: {%- if x -%}. trim_blocks drops the newline after a block tag and lstrip_blocks the indentation before it; the markdown preset turns both on. With line_statement set to "#", a line starting with # is a statement.
let tidy = { ..@moonpug.html, trim_blocks: true, lstrip_blocks: true } @moonpug.render(source, data, format=tidy)
Delimiters and shorthands
The strings that make a tag are settings, so a template can step out of the way of another language's punctuation:
let square = { ..@moonpug.delimiters, variable_start: "[[", variable_end: "]]", } @moonpug.render("Hi [[ name ]]! {{ stays }}", data, delimiters=square) // Hi Ada! {{ stays }}
Statement and comment pairs work the same way; -%}, +%}, raw and language blocks follow whatever pair is in force. The indentation syntax reads the same settings, both for its {{ }} interpolation and for statement lines.
The indentation syntax also takes the shorthand prefixes from the settings. By default . writes a class and # an id; a prefix of your own writes whatever attribute you name:
let settings = { ..@moonpug.settings, shortcuts: Map([(".", "class"), ("#", "id"), ("@", "data-role")]), } @moonpug.render("div@main.a#top", data, format=@moonpug.pug, shortcuts=settings.shortcuts) // <div class="a" data-role="main" id="top"></div>
The indentation syntax
pug
doctype html
html
body
nav#top.bar
each l in links
a(href=l.url class=['link', l.kind])= l.label
p Hello #{user.name}, #[strong welcome].
if items
ul: each i in items
li= i
else
p.
Nothing here yet.
Come back later.reads as
<!DOCTYPE html><html><body> <nav class="bar" id="top"> <a class="link …" href="…">…</a>… </nav> <p>Hello Ada, <strong>welcome</strong>.</p> <ul><li>…</li></ul> </body></html>
Attributes go in ( ), or [ ] and { } when a long list wraps; &attributes(map) merges a mapping in; :key is written as data-key. | starts a line of text and a trailing . makes the indented block text. < lines pass through as HTML, and {% %} lines are statements whose body is the indented block. Mixins take arguments, a block and attributes; include with an indented block puts it where the included file says yield; case takes when a, b.
Markdown that writes HTML
A Markdown template is rendered first and read as Markdown afterwards, the way a static site generator does it: render the template, then convert what it wrote. The output of a .md template is HTML, not Markdown.
# {{ title }}
{% for p in points %}
- {{ p }}
{% endfor %}
<!-- what the two steps write -->
<h1>Notes</h1>
<ul>
<li>ink</li>
<li>paper</li>
</ul>
Values are escaped before they enter the Markdown, so a tag in the data cannot become a tag in the page; the conversion runs afterwards and leaves the entities alone. Markdown of the value itself still works — it is text like any other. Turning escaping off with {% autoescape false %} is the one way to let a value's tags through.
HTML the template's author wrote is escaped too, unless the stretch says otherwise: Format.safe = false for the whole template, or {% block lang=md safe=false %} for one block. It is the switch a Markdown page calls html: false.
{% block lang=md safe=false %}
<div class="note"><p>body</p></div>
{% endblock %}
A language block inside Markdown is not converted again: it is rendered on its own, held under a mark, and put back once the Markdown around it is done. That is what keeps a block of Pug markup from being read as a paragraph.
# {{ title }}
{% block lang=pug %}
ul
each p in points
li= p
{% endblock %}
{% filter markdown %} and the indentation syntax's :markdown run the same conversion for a stretch of text, without registering anything. They write markup, so the result is not escaped again.
{% filter markdown %}
## From a string
{% endfilter %}
div
:markdown
## Or from a block
To write a Markdown file — a README.md in a generator, a changelog — use the text format: the template is still read with braces, and nothing is converted.
let file = { ..@moonpug.text, keep_trailing_newline: true } @moonpug.render("# {{ title }}\n", data, format=file) // # Notes\n
A language of your own ends the same way: Lang.finish takes what the stretch wrote and returns the page, and Lang carries it next to the reader. See your own language.
Language blocks
<article>
{% block lang=md %}
## {{ title }}
{% for p in points %}
- {{ p }}
{% endfor %}
{% endblock %}
{% block lang=pug %}
footer: small © {{ year }}
{% endblock %}
</article>
A whole template is one block in its own language; blocks inside may switch again, as deep as you like, and see the same variables. force=true on a block, Settings.force, or render(…, force=true) keep a stretch to its own language: inside pug and Markdown, blocks in another language stay as text and HTML still reads; inside HTML, pug and Markdown blocks stay as text.
Template sets
let env = @moonpug.Env::new(load=name => sources.get(name)) env.template("page.html").render(data)
{# layout.html #}
<title>{% block title %}Site{% endblock %}</title><main>{% block body %}{% endblock %}</main>
{# page.html #}
{% extends 'layout.html' %}
{% import 'ui.html' as ui %}
{% block title %}{{ name }} · {{ super() }}{% endblock %}
{% block body %}{{ ui.card(name, note) }}{% include 'footer.html' ignore missing %}{% endblock %}
Env::template reads a template through load, compiles it and keeps it. Blocks may be scoped (they see the loop around them) or required; the indentation syntax also has block append and block prepend. Paths in the indentation syntax are relative to the including file and may not climb above the loader's root.
Functions, filters and tests
let env = @moonpug.Env::new() env.add_global("site", Text("moonbitstack")) env.add_global("today", @moonpug.Value::func("today", (_, _, _) => Text("2026-10-25"))) env.add_filter("money", (call, value, args, named) => Text("¥" + call.text(value))) env.add_test("short", (call, value, args) => call.text(value).length() <= 3)
A filter receives the Call (whether output is being escaped, the undefined policy, the other filters), the value left of |, and the arguments. Registering a name that already exists replaces the built-in. Raise @moonpug.invalid(reason); the engine adds the template and line.
Host objects
impl @moonpug.Object for Cart with fn kind(_) { "the cart" } impl @moonpug.Object for Cart with fn attr(self, name) { match name { "total" => Some(Int(self.total())); _ => None } } impl @moonpug.Object for Cart with fn items(self) { Some(self.lines()) } env.compile(source).render_values({ "cart": Object(cart) })
| Method | Template | Default |
|---|---|---|
kind | messages | required |
attr | cart.total | no attributes |
call, callable | cart(), is callable | refused, false |
assign | {% set cart.coupon = … %} | refused |
show | {{ cart }} | <kind> |
items | for x in cart | not iterable |
What the program answers
Addresses, static files, form tokens and the clock belong to the program, not the template. Each is a global a program registers once; what is not registered is simply undefined.
let host : @moonpug.Host = { url: Some(fn(name, args, named) { routes.url(name, args, named) }), asset: Some(fn(path) { "/static/" + path }), asset_prefix: Some(fn { "/static/" }), media_prefix: Some(fn { "/media/" }), csrf_token: Some(fn { forms.token() }), now: Some(fn { clock.now_iso() }), } let env = @moonpug.Env::new() env.add_host(host~)
| Global | Answers |
|---|---|
url(name, …) | the address of a route, the rest of the arguments filling it |
static(path), static_prefix() | where static files are served from |
media_prefix() | where uploaded files are served from |
csrf_token() | the token a form carries, written as markup |
now(format) | the moment, in the date-format letters; the letters are applied here, so the program only says what time it is |
Everything else a template needs of the world goes through Env::add_global, which takes any Value::func; add_host is only a named set of the six a page usually wants. The same seams at a glance:
// the seams Env::new takes, each a function rather than a record field
@moonpug.Env::new(load=name => files.get(name), translate=translate, cache=store)
Your own language
fn read_csv(source : String, reading : @moonpug.Reading) -> Array[@syntax.Node] raise @moonpug.Refused { let nodes : Array[@syntax.Node] = [Text("<table>")] for line in source.split("\n") { nodes.push(Text("<tr>")) for cell in line.split(",") { nodes.append(reading.block(cell.trim().to_owned(), "html", None, reading.line)) } nodes.push(Text("</tr>")) } nodes.push(Text("</table>")) nodes } env.add_lang({ name: "csv", aliases: ["table"], options: @syntax.options, read: read_csv, finish: None, // the reader writes the page itself })
A reader turns source into the tree in moonbitstack/moonpug/syntax. Reading says where it stands (language, force, template, line, brace options, whether raw HTML is allowed) and reading.block reads a stretch in any registered language, so a reader can lean on the built-in ones. After add_lang, {% block lang=csv %} and Format.lang = "csv" both work.
Write the source of something that is not HTML — AsciiDoc, reStructuredText, a spreadsheet formula language — and give the language a finish: it receives what the stretch rendered and the Reading it rendered in, and returns what belongs in the page. Markdown's is the converter above.
let asciidoc : @moonpug.Lang = { name: "asciidoc", aliases: ["adoc"], options: @syntax.options, read: @syntax.read_braces, finish: Some((text, reading) => my_asciidoc_to_html(text, safe=reading.safe)), } env.add_lang(asciidoc)
Translation
let env = @moonpug.Env::new(translate=(message, context, count) => catalogue.find(message, context, count))
{% trans user=name %}Hello {{ user }}!{% endtrans %}
{% trans count=n %}{{ count }} item{% pluralize %}{{ count }} items{% endtrans %}
{% trans "menu" %}Open{% endtrans %}
{{ _('Hello') }} {{ ngettext('%(num)d apple', '%(num)d apples', n) }} {{ pgettext('menu', 'Open') }}
translate receives the message, its context and the plural count, and returns the translation or None to keep the source text. Translations are format strings filled with %(name)s; with escaping on they are trusted and the values escaped into them.
Caching and showing
{% once 'sidebar' %}{{ expensive() }}{% endonce %}
{% show expanded %}<div class="more">…</div>{% endshow %}
once stores what it renders through the cache function passed to Env::new, keyed by the expression; without one it renders every time. show writes its content either way and hides it with an inline style when the condition is false.
Settings
let settings = { ..@moonpug.settings, undefined: Lenient, line_statement: Some("#"), limits: { ..@moonpug.limits, depth: 200 }, } let env = @moonpug.Env::new(settings~)
| Field | Preset | Means |
|---|---|---|
format | html | how an unnamed source reads |
format.safe | true | whether HTML the author wrote reaches the page; false lets it through in Markdown |
formats | format_of | how a named template reads |
force | false | read only the template's own language |
undefined | Strict | what using a missing name does |
line_statement, line_comment | none | line prefixes |
delimiters | {{ }} {% %} {# #} | the strings that make a tag |
shortcuts | . class, # id | the indentation syntax's shorthand prefixes |
limits.write | 8 MiB | code units one render may write, into a capture as well |
limits.depth | 1000 | nested macro calls, includes, extends and recursive loops |
limits.range | 100 000 | the longest list range builds |
Configuration precedence
library preset < the record you pass < the same name passed to this call
In render(source, data, settings=s, format=f, force=b, delimiters=d, shortcuts=c, host=h), each named argument overrides the field of the same name in s, and s overrides the preset. Strict refuses a missing name unless the template asks is defined or gives a default; Lenient prints it as nothing; Chainable also lets its attributes be missing. The implicit else of an inline if is never refused.
Errors
let page = env.template("page.html").render(data) catch { Unknown(template~, line~, kind="name", name~) => "\{template}:\{line}: \{name} is missing" e => e.to_string() }
| Variant | When |
|---|---|
Syntax(template, line, column, reason) | the source is not well formed |
Unknown(template, line, kind, name) | a name, filter, test, template, language or attribute resolves to nothing |
Invalid(template, line, reason) | values that do not fit an operation or its arguments |
Exceeded(template, line, limit, got) | a bound in Limits was crossed |