Skip to content

cryMG/cryTemplate

Repository files navigation

cryTemplate

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. 🪄

NPM

Tests

Information and live demo (GitHub Pages): https://crymg.github.io/cryTemplate/

Why another template parser?

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.

Highlights

  • 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 }}), including dateformat
  • Dot-path lookups across a scope stack + simple fallbacks (||, ??)
  • Fail-safe parsing: malformed/unsupported tokens degrade to literal text (no runtime throws)

Usage

cryTemplate can be consumed as ESM, CJS, or directly in the browser.

ESM (Node.js / modern bundlers)

import { renderTemplate } from 'crytemplate';

const out = renderTemplate('Hello {{ name | trim | upper }}!', { name: '  Alex  ' });
// => "Hello ALEX!"

CJS (Node.js require)

const { renderTemplate } = require('crytemplate');

const out = renderTemplate('Hello {{ name }}!', { name: 'Alex' });
// => "Hello Alex!"

Browser (no bundler)

The browser bundles can be directly included via script tags.

From the dist/browser folder, use either:

  • dist/browser/crytemplate.js
  • dist/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>

Parse once, render many (advanced)

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

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:

  • name
  • user.name
  • user.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.

Escaping behavior

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-path resolution

Dot-paths resolve through objects:

Hello {{ user.name.first }}!

If any segment is missing, the result is empty.

Fallback operators (|| and ??)

Interpolations support fallbacks:

  • || (empty-ish): replaces undefined, null, '', [], {}
  • ?? (nullish): replaces only undefined and null

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 ''

Filters in interpolations

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

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 rules

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

Negation works with either ! or leading not:

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

Comparisons

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 %}

Logical operators and grouping

You can combine tests using && and || and group with parentheses:

{% if (a == 'x' && b) || c %}
  ok
{% endif %}

Precedence is && before ||.

Malformed control tokens

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

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 listExpr resolves to an object, the engine iterates own keys and exposes an entry object as the loop variable.

Iterating arrays

{% 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.

Iterating objects

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).

Scoping rules

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 }}

Nesting

Loops can be nested and combined with conditionals:

{% each users as u %}
  {% if u.active %}
    {{ u.name }}
  {% endif %}
{% endeach %}

Comments

Comments can be added using {# comment #} blocks:

{# This is a comment #}

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

Whitespace handling

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: \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.

Example:

{% if user %}
Hello {{ user.name }}
{% endif %}
Goodbye

Whitespace trimming markers

All 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

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 {{= ... }}.

Filter names

  • 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

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.

Built-in filters

upper

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

Example: {{ user.name | upper }}

lower

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

Example: {{ user.name | lower }}

trim

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

replace(from, to)

Performs a literal, global replacement on the string representation of the value.

  • from is coerced to string
  • to is coerced to string
  • if from is '', the input is returned unchanged

Examples:

  • {{ title | replace(' ', '-') }}
  • {{ title | trim | replace(' ', ' ') }}

string

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

number(decimals?, decimalSep?, thousandsSep?)

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 '' for null/undefined and otherwise String(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

json

Serializes the value via JSON.stringify(value).

  • If JSON.stringify returns undefined (e.g. for undefined), the filter returns ''.

Example: {{ obj | json }}

urlencode

Encodes the string representation of the value via encodeURIComponent(...).

Example: {{ query | urlencode }}

dateformat(format?)

Formats a date/time value.

Inputs:

  • Date
  • number (timestamp, milliseconds since epoch)
  • string (anything new 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, YY
  • M, MM
  • D, DD
  • H, HH
  • h, hh
  • m, mm
  • s, ss
  • Z (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]') }}
Dayjs integration

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

Custom filters

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).

Registering a filter

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).

Tests

Nearly all of the engine code is covered by unit tests.

Run the test suite with:

npm test

Benchmarks

You 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 bench

Optional knobs:

npm run bench -- --iterations=20000 --warmup=2000 --unique=128

Run a single mode only:

npm run bench -- --mode=parse
npm run bench -- --mode=render
npm run bench -- --mode=parse+render

Note

"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.

Credits

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.

License

MIT License. See LICENSE file for details.

Copyright (c) 2025-2026 cryeffect Media Group https://crymg.de, Peter Müller