Skip to content

feat!: implement more expressive templates via liquidjs - #428

Open
pausan wants to merge 2 commits into
useplunk:nextfrom
gamehouse-eu:liquidjs-templates
Open

feat!: implement more expressive templates via liquidjs#428
pausan wants to merge 2 commits into
useplunk:nextfrom
gamehouse-eu:liquidjs-templates

Conversation

@pausan

@pausan pausan commented Jul 26, 2026

Copy link
Copy Markdown

Description

Implement liquidjs templating system as per #426

Tests and documentation have been updated. I manually ran tests locally.

Templates are parsed once and rendered per recipient, so a basic template can render at 140k renders/s whereas a much richer template with several conditionals and loops renders at ~18k renders/s (worse-case scenario).

Even on a 1M-contact campaign, it would take less than 1 minute of CPU time for rendering, which is still negligible against 1M SES calls. I mean, worse-case scenario less than a minute for 1M emails, whereas best-case scenario less than 8 seconds (note: tested on an AMD Ryzen AI 9 365).

The engine is sandboxed: {% include %}, {% render %} and {% layout %} are rejected because they resolve partials off the file system, and parse/render/memory are capped so a runaway loop can't stall a worker.

Rendering stays lenient, a template that fails to parse falls back to plain {{variable}} substitution rather than failing the send. Syntax errors are surfaced at write time instead: POST/PATCH of templates and
campaigns, and POST /v1/send with an inline subject/body, now return 400 with the line and column.

Adding multiple tests to assess the proper behaviour and to keep some performance guarantees (for both memory & CPU).

Backwards compatible with existing template system for most scenarios.
See template-language.mdx Notes for existing templates for more info (in the commited files), but basically:

  • 0/false/NaN are now rendered while before they produced empty string
  • {{ name ?? 'hi' }} produced 'hi' with quotes, which seems like a bug, now emits hi with no quotes.
  • Body copy containing balanced {% %} markup is now executed rather than printed literally, wrap it in {% raw %} to keep it as text.

I think is very unlikely these create issues with existing templates.

Type of Change

Marking as breaking change since it is not 100% backwards compatible, even though my personal guess is that it might stay compatible with 99% campaigns or even higher, but I have no data to back this up, thus, breaking change it is.

  • feat: New feature (MINOR version bump)
  • fix: Bug fix (PATCH version bump)
  • feat!: Breaking change - new feature (MAJOR version bump)
  • fix!: Breaking change - bug fix (MAJOR version bump)
  • docs: Documentation update (no version bump)
  • chore: Maintenance/dependencies (no version bump)
  • refactor: Code refactoring (no version bump)
  • test: Adding tests (no version bump)
  • perf: Performance improvement (PATCH version bump)

PR Title Format

Testing

I created new automated tests both for template system itself and also for campaign service but also performance test to assess memory and CPU performance stays within reasonable boundaries.

The important caveat here is that I have not tested this in a live environment, given the change, it's totally worth to try it.

Most scenarios for the use of variables should be backwards compatible. See notes.

Checklist

  • PR title follows conventional commits format
  • Code builds successfully
  • Tests pass locally
  • Documentation updated (if needed)

Related Issues

Closes #426

pausan added 2 commits July 26, 2026 13:05
Templates are parsed once and rendered per recipient, so a basic
template can render at 140k renders/s whereas a richer one with
sevearl conditionals and loops renders at ~18k renders/s. 1M-contact
campaign spends under one minute of CPU time for rendering, which is
negligible against 1M SES calls.

The engine is sandboxed: {% include %}, {% render %} and {% layout %}
are rejected because they resolve partials off the file system, and
parse/render/memory are capped so a runaway loop can't stall a worker.

Rendering stays lenient, a template that fails to parse falls back to
plain {{variable}} substitution rather than failing the send. Syntax
errors are surfaced at write time instead: POST/PATCH of templates and
campaigns, and POST /v1/send with an inline subject/body, now return
400 with the line and column.

Adding multiple tests to assess the proper behaviour and to keep
some performance guarantees (for both memory & CPU).

Backwards compatible with existing template system for most scenarios.
See template-language.mdx Notes for existing templates for more info,
but basically: 0/false/NaN are now rendered while before they produced
empty string. Also {{ name ?? 'hi' }} produced 'hi' with quotes, which
seems like a bug, now emits hi with no quotes. Body copy containing
balanced {% %} markup is now executed rather than printed literally —
wrap it in {% raw %} to keep it as text.
verifyDomain and getDomainVerificationAttributes were already mocked, but
deleteIdentity and disableFeedbackForwarding were not, so the suite issued
live AWS SES requests on every run.

DomainService catches and logs failures from both, so the calls stayed
invisible in test output while still going over the network. With real SES
credentials in .env they would have executed DeleteIdentity and
SetIdentityFeedbackForwardingEnabled against a live AWS account.

DomainService.test.ts drops from ~40s to 23s.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Improved template system

1 participant