Scribe templates are plain text with three kinds of markup, borrowed from Twig / Jinja:
| Markup | Purpose |
|---|---|
{{ expression }} |
Print a value (HTML-escaped by default) |
{% tag %} |
Logic: if, for, set, include, extends/block, macro/import/from, verbatim |
{# comment #} |
A comment — never rendered |
Everything else is literal text and is copied to the output verbatim.
Hello {{ name }}!
{{ user.email }}
{{ items.0 }}
{{ price * quantity }}- The result is HTML-escaped (
< > & " '). This is on by default and is the recommended, secure behaviour. - To emit trusted HTML unescaped, pipe through the
rawfilter:{{ content | raw }}.
A bare name (title) is looked up first in the local scope (loop variables,
{% set %}), then in the context you passed to scribe_render.
Dotted paths walk into nested data:
{{ user.address.city }} {# nested maps #}
{{ tags.0 }} {# list index (0-based) #}A path segment that doesn't exist yields nothing, which prints as an empty string — templates don't crash on missing data.
Filters transform a value and chain left-to-right with |:
{{ name | upper }}
{{ title | lower | trim }}
{{ price | round }}
{{ items | join(', ') }}
{{ bio | default('No bio provided') }}Built-in filters:
| Filter | Effect |
|---|---|
upper |
Uppercase |
lower |
Lowercase |
capitalize |
First letter uppercase, rest lowercase |
title |
Capitalize the first letter of each word |
trim |
Strip leading/trailing whitespace |
length |
Length of a list or string |
reverse |
Reverse a list or string |
first / last |
First / last element (list) or character (string) |
join(sep) |
Join a list into a string with sep |
default(fallback) |
Use fallback when the value is empty/nothing |
replace(from, to) |
Replace every from with to |
abs / round |
Numeric absolute value / rounding |
truncate(len) / truncate(len, suffix) |
Shorten text to len characters, appending suffix (default "...") when it was cut |
striptags |
Remove every <…> HTML tag, leaving the text content: each tag becomes a word boundary, then runs of whitespace collapse to one space and the ends are trimmed |
date(format) |
Format a Date / Time / DateTime value with a strftime pattern (e.g. %Y-%m-%d) |
asset(base?) / url(base?) |
Build a URL: join an optional base with the value using exactly one slash; an empty base yields a root-relative path (/css/app.css) |
markdown |
Render a safe subset of Markdown (headings, paragraphs, lists, blockquotes, ``` fenced code blocks, **bold**, `italic`, `code`, `links`) to trusted HTML |
escape / e |
Explicitly HTML-escape (safe against double-escaping) |
raw |
Opt out of auto-escaping for this value |
The markdown filter HTML-escapes its input before applying formatting, so
untrusted content can't inject markup. Link targets may be relative URLs or use
the http, https, mailto, or tel schemes; every other explicit scheme
becomes #. This also rejects scheme obfuscation with leading browser-trimmed
control characters. The result is then trusted and printed without a second
round of escaping.
Blockquotes. Lines beginning with > gather into a <blockquote>, closed
by a blank line or by any non-quoted line. The quoted text is itself rendered as
Markdown, so quotes nest (> > deep) and inline formatting works inside them.
Nesting is bounded — past 32 levels the remainder is emitted as escaped plain
text rather than recursing further.
Fenced code blocks. A line whose first three characters are ``` opens a
block; everything up to the closing fence is emitted verbatim inside
<pre><code>…</code></pre>, HTML-escaped and with no Markdown applied. An info
string on the opening fence (```rust) is accepted and not emitted. An
unterminated fence still closes its block at the end of the input.
striptags and word boundaries. Each removed tag leaves a space behind,
then runs of whitespace collapse to a single space and the ends are trimmed. So
<p>One.</p><p>Two.</p> strips to One. Two. rather than welding into
One.Two. — which matters for the common excerpt idiom
{{ body | markdown | striptags | truncate(180) }}. The cost is that a tag
inside a word (un<b>frigging</b>believable) also splits it.
Filters and trusted text. Filters that only subset, trim, or strip their
input — trim, first, last, truncate, striptags — hand trusted text
back as trusted text, so a chain like markdown | striptags | truncate(80)
prints once-escaped rather than double-escaped (', not &#39;). On
that trusted path truncate escapes its suffix argument, since the result skips
output escaping and the suffix may be an arbitrary expression.
Inside {{ }} and in if / for / set you can write:
- Literals:
"double"or'single'strings, numbers (42,3.14),true,false,null. - Operators:
+ - * /,~(string concatenation), comparisons== != < > <= >=, and logicand,or,not. - Grouping: parentheses
( … ).
{{ 'Hi ' ~ user.name }}
{{ (a + b) * 2 }}{% if user.admin %}
Welcome, boss.
{% elseif user.member %}
Welcome back.
{% else %}
Please sign in.
{% endif %}Truthiness: nothing, false, 0, "", empty lists and empty maps are
false; everything else is true.
<ul>
{% for item in items %}
<li>{{ loop.index }}. {{ item.name }}</li>
{% else %}
<li>Nothing here yet.</li>
{% endfor %}
</ul>- Iterates a list. The optional
{% else %}block renders when the list is empty (or the value isn't a list). - Inside the loop, a
loopobject is available:
| Field | Meaning |
|---|---|
loop.index |
1-based position |
loop.index0 |
0-based position |
loop.first |
true on the first item |
loop.last |
true on the last item |
loop.length |
total number of items |
{% set full_name = user.first ~ ' ' ~ user.last %}
{{ full_name }}set binds a variable in the current scope. Inside a for loop the binding
is scoped to the loop body.
{# This note is for template authors only and never reaches the output. #}Render another template file inline, sharing the current context:
<body>
{{ content }}
{% include "partials/footer.html" %}
</body>The path is an expression, so {% include partial_name %} (a variable) works
too. Paths are resolved relative to the process's working directory.
Template source is trusted application code. Scribe does not impose a
filesystem sandbox: never derive include, import, or inheritance paths from
untrusted input unless the application validates them against its own template
root first. Include and macro-call nesting share a 50-level budget; exceeding it
renders [Scribe error: maximum template nesting exceeded].
Give a partial an explicit set of variables inline, instead of {% set %}-ing
them into the surrounding scope first:
{% include "partials/badge.html" with { label: "New", tone: "success" } %}The values are expressions evaluated in the caller's scope. Keys may be bare names or quoted strings. The extra variables are visible only while the partial renders; they don't leak back out.
Add only to isolate the partial — it then sees only the variables you
passed, not the caller's context or scope:
{% include "partials/badge.html" with { label: "New" } only %}A macro is a reusable, parameterised fragment — the building block for DRY
theme components. Define it, then call it like a function inside {{ … }}:
{% macro input(name, value) %}
<input name="{{ name }}" value="{{ value }}">
{% endmacro %}
{{ input("email", user.email) }}- Arguments bind to the parameters in order; a missing argument is
nothing. - A macro can be called before its definition appears (definitions are hoisted).
- Macro output is trusted — it isn't escaped again — but each
{{ … }}inside the macro is escaped as usual, so the pieces stay safe.
Keep a component library in its own file and pull it in:
{# components.html #}
{% macro button(label) %}<button>{{ label }}</button>{% endmacro %}
{% macro badge(text) %}<span class="badge">{{ text }}</span>{% endmacro %}Import the whole file under a namespace:
{% import "components.html" as ui %}
{{ ui.button("Save") }}
{{ ui.badge("New") }}…or import specific macros by name:
{% from "components.html" import button, badge %}
{{ button("Save") }}A base template defines named blocks with default content:
{# base.html #}
<!doctype html>
<title>{% block title %}My Site{% endblock %}</title>
<body>
<main>{% block content %}{% endblock %}</main>
{% include "footer.html" %}
</body>A child template extends the base and overrides the blocks it cares about; any block it leaves out keeps the base's default:
{% extends "base.html" %}
{% block title %}Home{% endblock %}
{% block content %}
<h1>Welcome</h1>
{% endblock %}{% extends %} must name the parent template; the child's content outside of
{% block %} tags is ignored (as in Twig).
Inheritance can be many levels deep: a base can itself {% extends %} a
grandparent, and so on. Each level may override blocks and introduce new ones
(a block can even be nested inside another block). When more than one level
defines the same block, the most-derived (closest to the leaf child) wins:
{# base.html — the page skeleton #}
<title>{% block title %}Site{% endblock %}</title>
<body>{% block body %}{% endblock %}</body>
{# blog.html — a theme layer on top of the base #}
{% extends "base.html" %}
{% block body %}<article>{% block content %}{% endblock %}</article>{% endblock %}
{# post.html — the page, on top of the theme #}
{% extends "blog.html" %}
{% block title %}My Post{% endblock %}
{% block content %}<h1>Hello</h1>{% endblock %}Emit Scribe syntax literally, without interpreting it:
{% verbatim %}
Here is how you print a variable: {{ name }}
{% endverbatim %}Everything between {% verbatim %} and {% endverbatim %} is copied as-is.
create map ctx:
"name" is "World"
end map
store out as scribe_render of "Hello {{ name }}!" and ctx
display out
// => Hello World!
Or render a template file:
store out as scribe_render_file of "templates/page.html" and ctx
The context is an ordinary WFL map. Nest maps and lists to build richer data; paths in the template walk them.
for key, value in mapis not supported (WFL does not expose map-key iteration); iterate lists instead.- No whitespace control (
{{-/-}}) yet — see the roadmap in DESIGN.md. - The
markdownfilter renders a deliberately small subset (headings, paragraphs, unordered lists, blockquotes, fenced code blocks,**bold**,*italic*,`code`, and[links](url)); it is not a full CommonMark implementation. Notably absent: ordered lists, tables, setext headings, reference links, and images.