cryTemplate

Usage

ESM, CJS, or directly in the browser. All examples render plain strings.

ESM (Node.js / bundlers)
Import and render a template.
import { renderTemplate } from 'crytemplate';

const out = renderTemplate('Hello {{ name | trim | upper }}!', { name: '  Alex  ' });
// => "Hello ALEX!"
CJS (Node.js require)
Use CommonJS require().
const { renderTemplate } = require('crytemplate');

const out = renderTemplate('Hello {{ name }}!', { name: 'Alex' });
// => "Hello Alex!"
Browser (no bundler)
Use the IIFE bundle and the global cryTemplate object. You can load it from a CDN provider like jsDelivr or UNPKG.
<!-- jsDelivr -->
<script src="https://cdn.jsdelivr.net/npm/crytemplate/dist/browser/crytemplate.min.js"></script>

<!-- UNPKG -->
<script src="https://unpkg.com/crytemplate/dist/browser/crytemplate.min.js"></script>

<script>
  const out = cryTemplate.renderTemplate('Hi {{ name }}!', { name: 'Alex' });
  console.log(out);
</script>
Parse once, render many (advanced)
Parse a template once and render it multiple times with different data.
import { renderTemplate, tplParse, tplRenderNodes } from 'crytemplate';

const nodes = tplParse('Hello {{ name | trim }}!');

const out1 = tplRenderNodes(nodes, [ { name: ' Alex ' } ]);
const out2 = tplRenderNodes(nodes, [ { name: ' Sam ' } ]);
// => "Hello Alex!" and "Hello Sam!"

// Scopes are a stack (array): last entry is the innermost scope.
const globals = { appName: 'cryTemplate' };
const data = { user: { name: 'Alex' } };
const out3 = tplRenderNodes(tplParse('App={{ appName }}, User={{ user.name }}'), [ globals, data ]);

// renderTemplate() also supports multiple scopes (rest args):
const out4 = renderTemplate('App={{ appName }}, User={{ user.name }}', globals, data);
Security note: Templates do not execute JavaScript. You cannot call functions from templates.

Syntax & Docs

Quick reference for the cryTemplate language. For the canonical, complete documentation see the repository README.

Interpolations

Insert values using double curly braces. Missing keys render as an empty string.

{{ title }}
{{ user.name.first }}

// If a key cannot be resolved it becomes "" (empty)
Important: Interpolations do not evaluate JavaScript. Only identifier/dot-path lookups, fallbacks (||, ??) and filters are supported.

Escaping

Escaped output is the default. Raw output is explicit.

{{ value }}   // escaped (default)
{{= value }}  // raw (no escaping)

Dot-path resolution

Use dot-paths to traverse nested objects.

Hello {{ user.profile.firstName }}!

Fallbacks

Choose between “empty-ish” vs “nullish” fallback behavior. Fallbacks are chainable left-to-right.

{{ user.name || user.email || 'anonymous' }}

{{ v || 'fallback' }}   // replaces undefined, null, '', [], {}
{{ v ?? 'fallback' }}   // replaces undefined, null only

Filters

Pipe values through filters, left-to-right. Unknown filters are ignored (fail-safe behavior).

{{ name | trim | upper }}
{{ price | number(2, ',', '.') }}
{{ createdAt | dateformat('YYYY-MM-DD') }}

Filter names

In templates, filter names are parsed as \w+. When registering filters, names must match ^[a-z][\w]*$.

Filter arguments

Arguments are optional and comma-separated.

{{ title | replace(' ', '-') | lower }}
{{ price | number(2, ',', '.') }}

Supported argument literals:

  • strings in single or double quotes (basic backslash escaping)
  • numbers (123, -1, 3.14)
  • booleans (true/false, case-insensitive)
  • null (case-insensitive)

Unsupported argument tokens are ignored.

Built-in filters

cryTemplate ships with the following filters:

  • upper, lower, trim, replace(from, to), string
  • number(decimals?, decimalSep?, thousandsSep?)
  • json(...args?), urlencode, dateformat(format?)

upper

Uppercases the string representation. Returns '' for null/undefined.

lower

Lowercases the string representation. Returns '' for null/undefined.

trim

Trims whitespace from the string representation. Returns '' for null/undefined. Optional mode: trim('left'), trim('right'), trim('both') (default).

replace(from, to)

Performs a literal, global replacement on the string representation. If from is '', the input is returned unchanged.

string

Forces an early string conversion: null/undefined'', otherwise String(value).

number(decimals?, decimalSep?, thousandsSep?)

Formats numbers. It tries Number(value) if the value is not already a number.

If the numeric result is not finite, it returns '' for null/undefined and otherwise String(value).

{{ price | number(2) }}           // 1234.50
{{ price | number(2, ',') }}      // 1234,50
{{ price | number(2, ',', '.') }} // 1.234,50

json(...args?)

Serializes the value to JSON using a custom safe stringifier. Unlike JSON.stringify, it does not execute toJSON() methods or getters, preventing code injection and side effects from malicious objects. Circular references are handled safely (emitted as null instead of crashing).

Arguments are detected by type and can be combined in any order: the string 'raw' disables the script-safe escaping, and a number sets the indentation for pretty-printed output.

By default the output is safe to embed inside a <script> block even with raw interpolation: <, >, & and U+2028/U+2029 are emitted as \uXXXX escapes (still valid, equivalent JSON), so a value like </script> cannot break out of the script context.

Use 'raw' only outside of an HTML/script context and when the exact byte representation matters (e.g. signing/hashing the raw string).

{{ obj | json }}          // compact, script-safe, e.g. {"u":"\u003c/script\u003e"}
{{ obj | json(2) }}       // indented with 2 spaces, script-safe
{{ obj | json('raw') }}   // compact, plain JSON, e.g. {"u":""}
{{ obj | json('raw', 2) }} // indented, plain (order does not matter)

urlencode

Encodes via encodeURIComponent(String(value)).

dateformat(format?)

Formats Date, timestamp numbers, or date-like strings. Invalid inputs return ''. Default format: YYYY-MM-DD HH:mm:ss.

{{ createdAt | dateformat('YYYY-MM-DD HH:mm:ss') }}
{{ createdAt | dateformat }}

Supported Day.js-style tokens (subset without Day.js): YYYY, YY, M/MM, D/DD, H/HH, h/hh, m/mm, s/ss, Z, A/a.

Escaping: anything inside [...] is treated as a literal and brackets are removed.

{{ createdAt | dateformat('YYYY-MM-DD [YYYY-MM-DD]') }}

Day.js integration (optional)

cryTemplate does not require Day.js, but you can plug it in for full formatting support.

import dayjs from 'dayjs';
import { setDayjsTemplateReference, renderTemplate } from 'crytemplate';

setDayjsTemplateReference(dayjs);

const out = renderTemplate("{{ d | dateformat('MMM YYYY') }}", { d: new Date() });

Conditionals

{% if user.admin %}
  Admin
{% elseif user.moderator %}
  Moderator
{% else %}
  User
{% endif %}

Truthiness

  • Arrays are truthy only when non-empty.
  • Objects are truthy only when they have at least one own key.
  • Everything else uses normal boolean coercion.

Negation

{% if !user.admin %}not admin{% endif %}
{% if not user.admin %}not admin{% endif %}

Comparisons, logical ops, grouping

{% if status == 'open' %}...{% endif %}
{% if age >= 18 %}adult{% endif %}
{% if (a == 'x' && b) || c %}ok{% endif %}

Precedence is && before ||.

Important: Tests are not JavaScript expressions. There is no arbitrary code execution.

Loops

Iterate arrays (and objects) with {% each ... %}.

{% each items as it %}
  - {{ it }}
{% endeach %}

Forms

  • Arrays: {% each listExpr as item %} ... {% endeach %}
  • Arrays with index: {% each listExpr as item, i %} ... {% endeach %}
  • Objects: iterates own keys and exposes { key, value } as the loop variable
{% each items as it, i %}
  {{ i }}: {{ it }}
{% endeach %}
{% each user as e %}
  {{ e.key }} = {{ e.value }}
{% endeach %}

Scoping

outside={{ it }}
{% each items as it %}
  inside={{ it }}
{% endeach %}
outside-again={{ it }}

Comments

Add comments using {# ... #} blocks. Comments are ignored during rendering.

{# This is a comment #}

{#
  This is also a comment.
  It can span multiple lines.
#}

Whitespace handling

cryTemplate performs a small whitespace normalization around control tags to make “block style” templates behave closer to what users typically expect.

  • Trim after control tags: exactly one line break immediately after a correctly parsed control tag closing %} is removed.
  • Supported line breaks: \n and \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.
{% if user %}
Hello {{ user.name }}
{% endif %}
Goodbye

In this example, the newline directly after {% if user %} and after {% endif %} is removed, so Hello ... and Goodbye render without extra blank lines.

Whitespace trimming markers

All tag types support optional trimming markers in the opening and/or closing delimiter.

  • - trims all whitespace (including newlines).
  • ~ trims whitespace but keeps newlines.
  • {{- / {%- / {#- 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, e.g. {{-= key -}} and {{~= key ~}}.
A \n  {{- name -}}  \nB   ->  AXB
A\n  {{~ name ~}}  \nB    ->  A\nX\nB
v={{-= html -}}           ->  v=<em>ok</em>

Robustness

Malformed or misplaced control tokens degrade to literal text instead of throwing at runtime.

In that case, the engine also keeps any following whitespace/newlines unchanged.

An {% if %} or {% each %} block that is never closed is auto-closed at the end of the template, so its content is preserved instead of being silently dropped.

For untrusted templates, recursion depth is capped: deeply nested conditions, long negation chains and deeply nested if/each blocks degrade gracefully instead of throwing a stack-overflow error. These limits are far above any realistic template.

Custom filters

You can register custom filters at runtime. Unknown filters in templates are ignored; registered filters are applied left-to-right.

Caution: Custom filters run your code. If you generate raw HTML or introduce unsafe behavior, you can reintroduce injection risks.

Registering a filter (Node/ESM)

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' });

Registering a filter (browser)

<script src="https://cdn.jsdelivr.net/npm/crytemplate/dist/browser/crytemplate.min.js"></script>
<script>
  cryTemplate.registerTemplateFilter('slug', (value) => {
    const s = (value === undefined || value === null) ? '' : String(value);
    return s.trim().toLowerCase().replace(/\s+/g, '-');
  });

  const out = cryTemplate.renderTemplate('Hello {{ name | slug }}!', { name: 'John Doe' });
</script>

Notes

  • The handler signature is (value: unknown, args?: (string | number | boolean | null)[]) => unknown.
  • Returning non-strings is allowed; the engine stringifies 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).

Try-it examples

Click “Run” to render. Output is shown as text.

Escaping vs raw
Shows why raw output is opt-in.
Escaped: {{ html }}
Raw: {{= html }}
{
  "html": "<strong>Trusted?</strong> & <script>alert(1)</script>"
}
Output

              
Filters
Trim + upper + dateformat.
Hello {{ name | trim | upper }}!
Today: {{ createdAt | dateformat('YYYY-MM-DD') }}
{
  "name": "  Alex  ",
  "createdAt": "2026-01-19T12:00:00Z"
}
Output

              
Fallbacks: || vs ??
See the semantic difference in one render.
nameOr: {{ name || 'fallback' }}
nameNullish: {{ name ?? 'fallback' }}
{
  "name": ""
}
Output

              
number() formatting
Decimals + separators.
US: {{ price | number(2, '.', ',') }}
DE: {{ price | number(2, ',', '.') }}
{
  "price": 1234.5
}
Output

              
json + urlencode
Serialize an object and encode it for a URL.
json: {{ obj | json }}
url: /search?q={{ obj | json | urlencode }}
{
  "obj": {
    "q": "ice cream",
    "page": 1
  }
}
Output

              
Whitespace trimming (- / ~)
Shows left/right trimming with interpolation and control tags.
[- removes newlines]
A
  {{- name -}}
B

[~ keeps newlines]
A
  {{~ name ~}}
B

[raw + trimming]
v= {{-= html -}}
end

[control tags]
X{% if show -%}
Y{% endif -%}
Z
X{%~ if show ~%}
Y{%~ endif ~%}
Z
{
  "name": "X",
  "html": "<em>ok</em>",
  "show": true
}
Output

              
Conditionals + loops
A practical snippet combining both.
{% if items %}
Items:
{% each items as it, i %}
  {{ i }}: {{ it | trim | replace(' ', '-') | lower }}
{% endeach %}
{% else %}
No items.
{% endif %}
{
  "items": [
    "  Coffee Beans  ",
    "Tea",
    "Brown Sugar"
  ]
}
Output

              
Want a full editor? Use the Playground.