Lightweight and safe-by-default string template engine with zero dependencies.
No code execution from templates – just string magic with interpolation, control flow, iteration, filters, and HTML escaping. 🪄
Information and live demo (GitHub Pages): https://crymg.github.io/cryTemplate/
Many existing template engines either allow arbitrary code execution (e.g., via eval or new Function), have heavy dependencies, or come with complex syntax and features that are overkill for simple templating needs.
cryTemplate is our answer to a minimal, secure, and easy-to-use template engine that covers common use cases without the risks and bloat of more complex solutions.
- Zero dependencies (pure TypeScript/JavaScript)
- Small and fast (~5 KiB minified + gzipped)
- HTML-escapes interpolations by default (
{{ ... }}) - Raw HTML output is explicit (
{{= ... }}) - No arbitrary JavaScript execution from templates (secure and predictable)
- Basics included: interpolations,
{% if %}conditionals,{% each %}loops, comments - Filters with a pipe syntax (
{{ value | trim | upper }}), includingdateformat - Dot-path lookups across a scope stack + simple fallbacks (
||,??) - Fail-safe parsing: malformed/unsupported tokens degrade to literal text (no runtime throws)
cryTemplate can be consumed as ESM, CJS, or directly in the browser.
import { renderTemplate } from 'crytemplate';
const out = renderTemplate('Hello {{ name | trim | upper }}!', { name: ' Alex ' });
// => "Hello ALEX!"const { renderTemplate } = require('crytemplate');
const out = renderTemplate('Hello {{ name }}!', { name: 'Alex' });
// => "Hello Alex!"The browser bundles can be directly included via script tags.
From the dist/browser folder, use either:
dist/browser/crytemplate.jsdist/browser/crytemplate.min.js
Or use a CDN like jsDelivr or UNPKG:
<script src="https://cdn.jsdelivr.net/npm/crytemplate/dist/browser/crytemplate.min.js"></script>
<script src="https://unpkg.com/crytemplate/dist/browser/crytemplate.min.js"></script>They expose a global cryTemplate object.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<script src="https://cdn.jsdelivr.net/npm/crytemplate/dist/browser/crytemplate.min.js"></script>
</head>
<body>
<script>
const out = cryTemplate.renderTemplate('Hi {{ name }}!', { name: 'Alex' });
console.log(out);
// logs "Hi Alex!"
</script>
</body>
</html>If you want to render the same template multiple times with different data, you can parse it once and re-use the parsed node list.
renderTemplate() is a convenience wrapper around:
tplParse(tpl)tplRenderNodes(nodes, scopes)
Where scopes is a stack (array) of objects.
Example:
import { tplParse, tplRenderNodes } from 'crytemplate';
const nodes = tplParse('Hello {{ name | trim }}!');
const out1 = tplRenderNodes(nodes, [ { name: ' Alex ' } ]);
const out2 = tplRenderNodes(nodes, [ { name: ' Sam ' } ]);
// => out1 === "Hello Alex!"; out2 === "Hello Sam!"tplRenderNodes() takes a scope stack. Resolution is safe-by-default (own properties only, no prototype traversal, no getter execution).
- The last entry is the innermost scope (it shadows earlier scopes).
- The first entry is the root scope.
import { renderTemplate, tplParse, tplRenderNodes } from 'crytemplate';
const nodes = tplParse('App={{ appName }}, User={{ user.name }}');
const globals = { appName: 'cryTemplate' };
const data = { user: { name: 'Alex' } };
// globals is the root scope, data is the innermost scope
const out = tplRenderNodes(nodes, [ globals, data ]);
// => "App=cryTemplate, User=Alex"
// renderTemplate() also supports multiple scopes (rest args)
const out2 = renderTemplate('App={{ appName }}, User={{ user.name }}', globals, data);Interpolations insert data into the output using double curly braces.
Supported forms:
{{ key }}: HTML-escaped insertion (default){{= key }}: raw insertion (no HTML escaping)
Where key is an identifier or dot-path identifier:
nameuser.nameuser.profile.firstName
If a key cannot be resolved, it becomes an empty string.
Important
Interpolations do not evaluate JavaScript. You cannot call functions from templates.
Only identifier/dot-path lookups, fallbacks (||, ??) and filters are supported.
By default, interpolation output is HTML-escaped (safe-by-default):
{{ title }}Raw insertion bypasses escaping:
{{= trustedHtml }}Only use raw insertion for already-sanitized, fully trusted HTML.
Dot-paths resolve through objects:
Hello {{ user.name.first }}!If any segment is missing, the result is empty.
Interpolations support fallbacks:
||(empty-ish): replacesundefined,null,'',[],{}??(nullish): replaces onlyundefinedandnull
Fallbacks are chainable left-to-right:
Hello {{ user.name || user.email || 'anonymous' }}Examples showing the semantic difference:
{{ v || 'fallback' }} // replaces '' but keeps 0 and false
{{ v ?? 'fallback' }} // replaces null/undefined but keeps ''You can pipe the resolved value through one or more filters:
{{ user.name | trim | upper }}
{{ price | number(2, ',', '.') }}
{{ createdAt | dateformat('YYYY-MM-DD') }}Filters are applied left-to-right, and unknown filters are ignored.
See below for details on built-in and custom filters.
Conditionals provide minimal control flow using {% ... %} blocks.
Supported tags:
{% if test %}{% elseif test %}{% else %}{% endif %}
Example:
{% if user.admin %}
Admin
{% elseif user.moderator %}
Moderator
{% else %}
User
{% endif %}Important
Tests are not JavaScript expressions. There is no arbitrary code execution. If a test is malformed/unsupported, the engine degrades safely.
Truthiness is intentionally simple and predictable:
- Arrays are truthy only when non-empty (
[1]→ true,[]→ false) - Objects are truthy only when they have at least one own key (
{k:1}→ true,{}→ false) - Everything else uses normal boolean coercion (
0→ false,'x'→ true)
Examples:
{% if items %}has items{% else %}no items{% endif %}
{% if obj %}has keys{% else %}empty{% endif %}Negation works with either ! or leading not:
{% if !user.admin %}not admin{% endif %}
{% if not user.admin %}not admin{% endif %}Supported comparison operators:
==,!=(equality)>,<,>=,<=(numeric/string comparisons)
The left-hand side is usually a key, the right-hand side can be:
- a literal:
'text',123,3.14,true,false,null - another key
Examples:
{% if status == 'open' %}...{% endif %}
{% if age >= 18 %}adult{% endif %}
{% if lhs > rhs %}...{% endif %}
{% if v == null %}missing{% endif %}You can combine tests using && and || and group with parentheses:
{% if (a == 'x' && b) || c %}
ok
{% endif %}Precedence is && before ||.
Misplaced or invalid control tokens degrade to literal text instead of throwing at runtime. This keeps rendering fail-safe even on partially broken templates.
Loops are implemented with an {% each ... %} block.
Supported forms:
- Arrays:
{% each listExpr as item %}...{% endeach %} - Arrays with index:
{% each listExpr as item, i %}...{% endeach %} - Objects: if
listExprresolves to an object, the engine iterates own keys and exposes an entry object as the loop variable.
{% each items as it %}
- {{ it }}
{% endeach %}With an index variable:
{% each items as it, i %}
{{ i }}: {{ it }}
{% endeach %}If the array is empty, the loop renders nothing.
If the list expression is an object, the loop variable is an entry object with key and value:
{% each user as e %}
{{ e.key }} = {{ e.value }}
{% endeach %}Object iteration uses the engine's normal key enumeration order (insertion order in modern JS engines).
Each loop introduces a nested scope:
- The loop variable (and optional index variable) exist only inside the loop body.
- Outer variables with the same name are shadowed inside the loop, but remain unchanged outside.
Example:
outside={{ it }}
{% each items as it %}
inside={{ it }}
{% endeach %}
outside-again={{ it }}Loops can be nested and combined with conditionals:
{% each users as u %}
{% if u.active %}
{{ u.name }}
{% endif %}
{% endeach %}Comments can be added using {# comment #} blocks:
{# This is a comment #}
{#
This is also a comment.
It can span multiple lines.
#}cryTemplate performs by default a small whitespace normalization around control tags to make “block style” templates behave closer to what users typically expect.
Rules:
- Trim after control tags: exactly one line break immediately after a correctly parsed control tag closing
%}is removed. - Supported line breaks:
\nand\r\n. - Only one: if multiple line breaks follow, only the first one is removed.
- No other trimming: spaces/tabs after
%}are not removed. - Fail-safe behavior: misplaced/malformed control tokens preserved as literal text do not trigger trimming.
Example:
{% if user %}
Hello {{ user.name }}
{% endif %}
GoodbyeAll tag types support optional whitespace trimming markers in the opening and/or closing delimiter:
-trims all whitespace (including newlines)~trims whitespace but keeps newlines
Rules:
{{-/{%-/{#-trims whitespace before the tag-}}/-%}/-#}trims whitespace after the tag{{~/{%~/{#~and~}}/~%}/~#}work the same, but do not remove line breaks- Raw interpolations can be combined with trimming markers:
{{-= key -}}and{{~= key ~}}
Examples:
A \n {{- name -}} \nB -> AXB
A\n {{~ name ~}} \nB -> A\nX\nB
v={{-= html -}} -> v=<em>ok</em>Filters can be applied to interpolations using a pipe syntax:
{{ key | filterName }}
{{ key | filterName(arg1, arg2) | otherFilter }}Filters are applied left-to-right. After all filters have been applied, the final value is converted to a string (null/undefined become an empty string) and then HTML-escaped unless you use the raw interpolation form {{= ... }}.
- In templates, filter names are parsed as
\w+(letters, digits, underscore). - When registering filters in JS/TS, the current registry enforces lowercase names that match:
^[a-z][\w]*$.
Unknown filter names referenced in templates are ignored at render time (fail-safe behavior).
Filter arguments are optional and comma-separated:
{{ title | replace(' ', '-') | lower }}
{{ price | number(2, ',', '.') }}Supported argument literals:
- strings in single or double quotes (supports basic backslash escaping)
- numbers (
123,-1,3.14) - booleans (
true/false, case-insensitive) null(case-insensitive)
Other/unsupported argument tokens are ignored.
Uppercases the string representation of the value. Returns '' for null/undefined.
Example: {{ user.name | upper }}
Lowercases the string representation of the value. Returns '' for null/undefined.
Example: {{ user.name | lower }}
Trims whitespace from the string representation of the value. Returns '' for null/undefined.
Optional mode (first argument):
trim('left')→trimStart()trim('right')→trimEnd()trim('both')→trim()(default)
Unknown mode values fall back to both.
Example: {{ user.name | trim }} or {{ user.name | trim('left') }} / {{ user.name | trim('right') }}
Performs a literal, global replacement on the string representation of the value.
fromis coerced to stringtois coerced to string- if
fromis'', the input is returned unchanged
Examples:
{{ title | replace(' ', '-') }}{{ title | trim | replace(' ', ' ') }}
Coerces to string early.
null/undefined→''- otherwise →
String(value)
This can be useful for chaining when you want to be explicit about string conversion.
Example: {{ value | string | replace('x', 'y') }}
Formats numeric output.
- Attempts to convert the value via
Number(value)if it is not already a number. - If the result is not a finite number, it returns
''fornull/undefinedand otherwiseString(value).
Signatures:
number()number(decimals)number(decimals, decimalSep)number(decimals, decimalSep, thousandsSep)
Examples:
{{ price | number(2) }}→1234.50{{ price | number(2, ',') }}→1234,50{{ price | number(2, ',', '.') }}→1.234,50
Serializes the value via JSON.stringify(value).
- If
JSON.stringifyreturnsundefined(e.g. forundefined), the filter returns''.
Example: {{ obj | json }}
Encodes the string representation of the value via encodeURIComponent(...).
Example: {{ query | urlencode }}
Formats a date/time value.
Inputs:
Datenumber(timestamp, milliseconds since epoch)string(anythingnew Date(value)can parse)
If the input cannot be parsed as a valid date, the filter returns ''.
By default, it formats using:
YYYY-MM-DD HH:mm:ss
Example:
{{ createdAt | dateformat('YYYY-MM-DD HH:mm:ss') }}
{{ createdAt | dateformat }}Supported formatting tokens in Day.js-style:
YYYY,YYM,MMD,DDH,HHh,hhm,mms,ssZ(timezone offset in±HH:mm)A,a(AM/PM)
Escaping: anything inside [...] is treated as a literal and the brackets are removed.
Example:
{{ createdAt | dateformat('YYYY-MM-DD [YYYY-MM-DD]') }}cryTemplate does not require Day.js, but you can enable full Day.js formatting by providing a Day.js reference.
setDayjsTemplateReference(dayjs) sets the reference that the dateformat filter will use.
Passing null clears it and reverts to the built-in token subset.
Example:
import dayjs from 'dayjs';
import { setDayjsTemplateReference, renderTemplate } from 'crytemplate';
setDayjsTemplateReference(dayjs);
const out = renderTemplate("{{ d | dateformat('MMM YYYY') }}", { d: new Date() });You can create and register your own filters at runtime.
Caution
By implementing custom filters, you take responsibility for ensuring that they do not introduce security vulnerabilities (e.g., via code execution or unsafe HTML generation).
Import registerTemplateFilter and register a handler:
import { registerTemplateFilter, renderTemplate } from 'crytemplate';
registerTemplateFilter('slug', (value) => {
const s = (value === undefined || value === null) ? '' : String(value);
return s.trim().toLowerCase().replace(/\s+/g, '-');
});
const out = renderTemplate('Hello {{ name | slug }}!', { name: 'John Doe' });Notes:
- The handler signature is
(value: unknown, args?: (string | number | boolean | null)[]) => unknown. - Returning non-strings is allowed; the engine will stringify after the filter pipeline.
- Register filters once during application startup (the registry is global to the module).
- Re-registering the same name overrides the previous handler (including built-ins).
Nearly all of the engine code is covered by unit tests.
Run the test suite with:
npm testYou can run a micro-benchmark that measures:
parse: parsing/compiling only (tplParse)render: rendering only on pre-parsed templates (tplRenderNodes)parse+render: parse + render in the hot loop
npm run benchOptional knobs:
npm run bench -- --iterations=20000 --warmup=2000 --unique=128Run a single mode only:
npm run bench -- --mode=parse
npm run bench -- --mode=render
npm run bench -- --mode=parse+renderNote
"Parsing" is not standardized across engines. Some libraries expose a parser, others only a compile step. The benchmark output is therefore indicative only, and engines differ in features and security model.
This project was developed with the assistance of AI tools (e.g., GitHub Copilot/ChatGPT) to optimize code structure and efficiency. All logic has been reviewed and tested by the author.
MIT License. See LICENSE file for details.
Copyright (c) 2025-2026 cryeffect Media Group https://crymg.de, Peter Müller