moonpug
Guide

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

NameAlsoReadsPreset
htmltext with {{ }}, {% %}, {# #}html escaped, text not
pugpgelements by indentationpug
mdmarkdownbraces, then Markdownmarkdown
@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 }}              <!-- &lt;b&gt;x&lt;/b&gt; -->
{{ 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) })
MethodTemplateDefault
kindmessagesrequired
attrcart.totalno attributes
call, callablecart(), is callablerefused, false
assign{% set cart.coupon = … %}refused
show{{ cart }}<kind>
itemsfor x in cartnot 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~)
GlobalAnswers
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~)
FieldPresetMeans
formathtmlhow an unnamed source reads
format.safetruewhether HTML the author wrote reaches the page; false lets it through in Markdown
formatsformat_ofhow a named template reads
forcefalseread only the template's own language
undefinedStrictwhat using a missing name does
line_statement, line_commentnoneline prefixes
delimiters{{ }} {% %} {# #}the strings that make a tag
shortcuts. class, # idthe indentation syntax's shorthand prefixes
limits.write8 MiBcode units one render may write, into a capture as well
limits.depth1000nested macro calls, includes, extends and recursive loops
limits.range100 000the 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()
}
VariantWhen
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