A Twig-inspired templating engine written entirely in WFL — the WebFirst Language, where programs read like plain English.
Scribe brings familiar {{ variable }} / {% logic %} / {# comment #}
templating to WFL, with HTML auto-escaping on by default so your output is
secure unless you explicitly opt out.
<h1>{{ post.title }}</h1>
{% if post.published %}
<p>{{ post.body }}</p>
{% else %}
<p><em>draft</em></p>
{% endif %}
<ul>
{% for tag in post.tags %}
<li>{{ loop.index }}. {{ tag | upper }}</li>
{% endfor %}
</ul>- Familiar. If you know Twig, Jinja, or Liquid, you already know Scribe.
- Secure by default. Every
{{ … }}is HTML-escaped. Opt out locally and explicitly with{{ trusted | raw }}— never globally. (WFL Principle 8.) - Pure WFL. No Rust, no host-language escape hatch. Scribe is one WFL file that runs anywhere WFL runs.
- Small and tested. A readable lexer → parser → renderer pipeline with an 89-case test suite.
- Output with auto-escaping:
{{ user.name }} - Dotted paths into nested maps and lists:
{{ order.items.0.title }} - Filters, chainable:
{{ name | upper }},{{ items | join(', ') }} - Filter set:
upper, lower, capitalize, title, trim, length, reverse, first, last, join, default, replace, abs, round, truncate, striptags, date, asset, url, markdown, escape/e, raw - Conditionals:
{% if %}/{% elseif %}/{% else %}/{% endif %} - Loops with a
loophelper:{% for x in items %}…{% else %}…{% endfor %}, plusloop.index / index0 / first / last / length - Variables:
{% set total = a + b %} - Expressions:
+ - * /,~concat,== != < > <= >=,and / or / not, parentheses, string & number literals,true / false / null - Macros:
{% macro name(a, b) %}…{% endmacro %}, called as{{ name(x, y) }}; share them across files with{% import "lib.html" as ui %}(then{{ ui.name(...) }}) or{% from "lib.html" import name %} - Includes:
{% include "partial.html" %}(shares the current context), or with an explicit scoped context:{% include "partial.html" with { key: value } %}— addonlyto isolate the partial from the caller's variables - Multi-level template inheritance:
{% extends "base.html" %}with{% block name %}…{% endblock %}overrides and defaults, chained as many levels deep as you like (child → theme → base) - Markdown via the
markdownfilter: headings, paragraphs, lists, blockquotes (nested),``` fenced code blocks, emphasis, inline code, and safe links — escaped first, with relative URLs plushttp, `https`, `mailto`, and `tel` allowed - Comments:
{# … #} - Raw blocks:
{% verbatim %}…{% endverbatim %}
See docs/SYNTAX.md for the full language reference and docs/DESIGN.md for the architecture.
Scribe is a single WFL library file, src/scribe.wfl. Pull it into your own
program with include from, which exposes its actions to you:
// my_app.wfl — your program
include from "src/scribe.wfl"
create map ctx:
"name" is "World"
"items" is ["one", "two", "three"]
end map
store out as scribe_render of "Hello {{ name }}! ({{ items | length }} items)" and ctx
display out
Run it:
wfl my_app.wfl
# => Hello World! (3 items)The include from path is resolved relative to your file's directory, so
adjust it to wherever you keep src/scribe.wfl.
The public API is two actions:
| Action | Description |
|---|---|
scribe_render of template and context |
Render a template string with a context map → returns the output text |
scribe_render_file of path and context |
Read a template file from disk, then render it |
The context is an ordinary WFL map. Nest maps and lists to model richer data.
Scribe gives each render a cryptographically random internal provenance token,
so context maps cannot forge trusted-HTML values, macros, or imported macro
namespaces by copying their reserved key shapes.
Template source remains trusted application code. In particular, raw opts
out of escaping, and include, import, from, and extends read the path the
template supplies. Do not derive those paths from untrusted input unless the
application validates them against its own template root first. Includes and
macro calls share a 50-level nesting budget to prevent cyclic templates from
exhausting the interpreter stack.
examples/blog.wfl renders a small blog page end-to-end:
examples/run.sh examples/blog.wfl /path/to/wflexamples/inheritance.wfl shows {% extends %} / {% block %} / {% include %}
against the templates in examples/templates/ (run it from the repo root so the
relative paths resolve). Expected output for both is checked in next to each
example (*.expected.html).
examples/theme.wfl puts the ecosystem features together as a theme layer:
multi-level {% extends %} (child → blog shell → base skeleton), an
{% import %}-ed macro library, an {% include ... with { … } only %} partial,
and the markdown / asset / url filters.
The suite uses WFL's built-in describe / test framework and pulls in the
engine with include from:
tests/run.sh /path/to/wfl
# Total: 89 Passed: 89 Failed: 0Note on WFL's static checker. The WFL type checker prints some
could not infer typenotes to stderr for calls into the engine's actions. These are non-fatal — the program runs and the tests exit 0.
Scribe/
├── src/scribe.wfl # the entire engine (pure library)
├── tests/scribe.test.wfl # test suite (89 cases), includes the engine
├── tests/run.sh # run the tests with `wfl --test`
├── examples/blog.wfl # worked example + expected output
├── examples/inheritance.wfl # inheritance example + expected output
├── examples/theme.wfl # theme-layering example (macros, multi-level, filters)
├── examples/run.sh # run an example
└── docs/ # SYNTAX.md and DESIGN.md
Implemented: multi-level inheritance, macros ({% macro %} / {% import %} /
{% from … import %}), scoped {% include … with { … } %}, and the
date / truncate / striptags / asset / url / markdown filters.
Planned next: for key, value in map (pending WFL map-key iteration),
whitespace control ({{- / -}}), and more filters/functions. Details in
docs/DESIGN.md.
Building a non-trivial program in WFL surfaced a few language bugs, reported upstream — all since addressed:
- wfl#582 — a function
parameter was overridden by a same-named global variable. Fixed upstream.
Scribe still prefixes every engine parameter
sc_defensively (harmless, and keeps it working on older WFL builds). - wfl#583 — the string
value
"[]"was coerced to an empty list. Fixed upstream. Separately, Scribe now renders an empty collection as empty text (Twig's behaviour), so a bare{{ empty_list }}no longer emits the literal[]. - wfl#584 —
load modulesymbols are invisible to the analyzer. Resolved by usinginclude frominstead, which exposes the included file's actions — this is how Scribe loads its engine.
Apache-2.0. See LICENSE.