owner @Hindurable
repository https://github.com/nurl-lang/nurl-lang
[dependencies] template = "^0.2.6"
0.2.6 · 2026-10-22 · @Hindurable · files · api0.2.5 · 2026-10-16 · @Hindurable · files · api0.2.2 · 2026-10-10 · @Hindurable · files · api0.2.1 · 2026-10-08 · @Hindurable · files · api0.2.0 · 2026-10-03 · @Hindurable · files · api0.1.2 · 2026-09-28 · @Hindurable · files · api0.1.1 · 2026-09-16 · @Hindurable · files · api0.1.0 · 2026-07-07 · @Hindurable · files · apiNone.
Jinja-flavoured templates rendered against a stdlib Json context. HTML-escaped by default, lenient on missing data, strict on malformed tags. The engine is pure text → text with no fs or http dependency; a directory loader and a small CLI ship alongside it.
<ul>
{% for item in items %}
<li class="{% if loop.first %}first{% end %}">
{{ loop.index }}: {{ item.title | upper }}
{% if item.url %}<a href="{{ item.url }}">link</a>{% end %}
</li>
{% end %}
</ul>
{% include 'footer' %}
$ `deps/template/src/template.nu`
// ctx: e.g. ( json_parse src ), or built up
?? ( tpl_render `Hi {{ name }}!` ctx ) {
T html → {}
F err → {} // "template error at line L, col C: …"
}
| Call | |
|---|---|
( tpl_render src ctx ) → !String String | one-shot render |
( tset_new ) → TplSet | named-template set (include targets); a handle, released by its last owner |
( tset_add t name src ) | register / replace (copies both) |
( tset_has t name ) → b | |
( tset_render t name ctx ) → !String String | render a set member |
( tpl_render_with t src ctx ) → !String String | one-shot, includes resolve in t |
( tset_free t ) | optional early release |
( tset_load_dir t dir ext ) → i | src/loader.nu: load dir/*<ext>, named by basename; -1 when the glob fails |
The context is any Json value; top-level lookups expect a JObj. The result and error payloads are owned Strings, dropped with their bindings.
| Form | |
|---|---|
{{ expr }} | output, HTML-escaped (& < > " ') |
{{ expr | raw }} | unescaped output |
{{ expr | upper }} / | lower | case-fold strings |
{{ expr | length }} | string byte length / array length |
{{ expr | json }} | stringify an array/object (escaped; add | raw for raw JSON) |
{% if expr %} … {% elif expr %} … {% else %} … {% end %} | endif also accepted |
{% for name in path %} … {% end %} | iterate a JSON array; endfor also accepted |
{% include 'name' %} | render a set member in the current context + scope |
{# … #} | comment |
Expressions are a dotted path (user.name, rows.0.id — numeric segments index arrays), a 'string' / "string" literal, a number, or true/false, optionally combined as a == b / a != b or prefixed with not. Inside a {% for %} body the innermost loop exposes loop.index (0-based), loop.first, loop.last, loop.length; the loop variable shadows context keys, inner loops shadow outer ones.
Truthiness: missing keys and null are falsy, as are false, 0, "", and []; objects are truthy. Missing keys render as empty, so {{ maybe }} is safe without a guard — but a typo'd tag, filter, or operator is a hard error, not silence.
Equality compares like kinds only: numbers numerically, strings bytewise, booleans as booleans; a string never equals a number.
Failures return an owned String like template error at line 3, col 12: '{% if %}' without matching '{% end %}'. Include cycles are cut at depth 16. Known v0.1 limits: }} / %} inside a string literal ends the tag early; error positions inside an included template are reported against the including source.
template -f page.html -c ctx.json -p views/
echo '{{ msg | upper }}' | template -c ctx.json
-f template file (default stdin), -c JSON context file (default {}), -p a partials directory (*.html, named by basename) for {% include %}.
Nothing is freed by hand. tpl_render's Ok/Err payloads are owned Strings, dropped with their bindings. The context Json stays caller-owned (the engine renders against its own copy). A TplSet is a handle: every copy (a struct field, a Vec element, a closure capture) is the same set, and its last owner releases it — tset_free is an optional early release. tset_add copies its arguments. The engine keeps one renderer per call behind a handle of its own, released when the call returns — the test suite runs clean under ASan/LSan.
nurlpkg test (or ./nurl.sh tests/<t>.nu from the package dir) runs four self-asserting suites: basic.nu (variables, escaping, filters, literals, error paths), control.nu (if/elif/else, for, loop.*, nesting, conditions), includes.nu (sets, include, cycles, the directory loader), handles.nu (copies of a TplSet share one set, tset_free as an early release, the loader on a missing directory).
Linux CI also measures these suites with nurl-cov and requires at least 96.3% line coverage of the imported src/ modules (initial baseline: 747/776 lines). Branch and function coverage are reported but have no minimum. The template-coverage artifact contains an HTML report, including when the coverage floor fails, and is retained for seven days.
To reproduce from the repository root after ./build.sh:
export NURL_STDLIB="$PWD"
export NURL_CC="$PWD/nurl.sh"
cd packages/nurl-cov
../../nurl.sh -O0 src/main.nu ../../build/nurl-cov
cd ../template
../../build/nurl-cov run --work ../../build/template-coverage \
--no-color --fail-under 96.3 --html ../../build/template-coverage.html
The coverage run clears previous counters before measuring. Keep the CI floor when adding code; cover the new paths with tests.