Skip to content

Architecture

NLT uses a clean two-phase pipeline: template text is first parsed into an AST, then the AST is walked to produce the final HTML output. The entire engine is implemented with zero third-party dependencies.


Two-Phase Pipeline

Template string
|
v
┌──────────────────────────────────────────────┐
│ Phase 1: TOKENIZER (tokenizer.py) │
│ │
│ 0. Normalize apostrophe variants to ASCII ' │
│ 1. Extract `{raw}`...`{end raw}` to placeholders│
│ 2. Strip comments (`{# ... #}`) │
│ 3. Clean HTML tags inside {..} blocks │
│ 4. Extract {..} blocks into flat token list │
│ 5. Classify tokens by keyword prefix │
│ 6. Build nested AST (if/else, for, blocks) │
└──────────────────────────────────────────────┘
|
v
list[ASTNode]
|
v
┌──────────────────────────────────────────────┐
│ Phase 2: RENDERER (renderer.py) │
│ │
│ 1. Resolve template inheritance ({extends}) │
│ 2. Walk AST nodes sequentially │
│ 3. Dispatch by node type: │
│ LiteralNode → emit text unchanged │
│ ExpressionNode → resolve + escape + emit │
│ IfNode → evaluate + branch │
│ ForNode → iterate + render body │
│ ListNode → resolve list + HTML │
│ TableNode → resolve data + table │
│ AggregateNode → compute sum/avg/count │
│ SetNode → evaluate + store var │
│ RawNode → emit content verbatim │
│ DefineMacroNode → register macro │
│ UseMacroNode → invoke macro │
│ BlockNode → render body nodes │
│ 4. HTML-escape all resolved user data │
│ 5. Track suppression state │
└──────────────────────────────────────────────┘
|
v
RenderResult(html, subject, suppressed, suppressed_reason)

The two phases are fully decoupled. The tokenizer produces a list[ASTNode] that the renderer consumes. You can inspect the AST directly by calling tokenize() without rendering.


Module Structure

__init__.py ──────────── Public API exports
├── renderer.py ──── Main rendering engine
│ │
│ ├── tokenizer.py ──── Template → AST
│ │ │
│ │ ├── source_parser.py ── Shared source extraction
│ │ ├── list_parser.py ──── {list} block parsing
│ │ ├── table_parser.py ─── {table} block parsing
│ │ ├── condition_parser.py {if} condition parsing
│ │ └── nodes.py ──── AST dataclasses
│ │
│ ├── parser.py ──── Expression text → NLTExpression
│ ├── comparators.py ── Condition evaluation
│ ├── formatters.py ─── Currency, date, percentage, pluralize
│ └── filters.py ────── 19 chainable filter functions
├── converter.py ──── Bidirectional NLT ↔ Jinja2
└── jinja2_compat.py ── Backward-compat re-exports
ModuleResponsibility
__init__.pyPublic API surface. Re-exports all public types and functions.
renderer.pyAST to HTML rendering. Data resolution. Math operations. Expression dispatch. Template inheritance. Macro invocation.
tokenizer.pyTemplate string to AST node list. Block classification. Nested structure building. Comment stripping. HTML artifact cleaning.
parser.pyExpression text to NLTExpression dataclass. Source identification. Math extraction. Modifier and filter parsing.
nodes.pyAll 14 AST node dataclasses plus supporting types (ConditionClause, CompoundCondition, ListConfig, TableConfig).
condition_parser.py{if} condition text to AnyCondition tree. Operator matching. AND/OR splitting. NOT negation via De Morgan's law.
comparators.pyCondition evaluation. Numeric, string, date, containment, and existence comparisons.
formatters.pyCurrency formatting (17 currencies). Date formatting (presets and custom tokens). Percentage formatting. Pluralization. Title case.
filters.py19 filter implementations. Filter dispatch map. Pure functions: (value, arg) -> value.
source_parser.pyShared source phrase extraction used by tokenizer, list_parser, and table_parser. Extracts (source, attribute_path, event_name) tuples.
list_parser.py{list ...} block text to ListConfig. Style and limit parsing.
table_parser.py{table ...} block text to TableConfig. Column extraction.
converter.pyBidirectional NLT/Jinja2 conversion. Template language detection. Expression counting.

Expression Resolution Order

When the renderer encounters an ExpressionNode, it tries these resolution strategies in order. The first non-None result wins:

  1. Loop variables -- {item.Name}, {loop.index} (checked from innermost to outermost loop scope)
  2. Macro parameters -- {name} inside a {define} body (checked from innermost to outermost macro scope)
  3. Local variables -- {discount} set via {set discount to ...}
  4. Ternary expressions -- {condition ? "true_val" : "false_val"}
  5. Built-in variables -- {today}, {now}
  6. Standard NLT expression -- {the user's ...}, {the ... from the trigger event}, etc. Parsed via parse_nlt_expression().

If none match and parsing fails, the original {...} text is emitted unchanged. This is intentional -- graceful degradation means a typo never crashes the rendering pipeline.

Note

The resolution order matters when names collide. A loop variable {item} will shadow a local variable with the same name. A macro parameter {name} will shadow a local variable {name} set outside the macro.


Security Model

XSS Prevention

All user-supplied data resolved from the contact, events, or computed values is HTML-escaped before insertion into the output:

CharacterReplacement
&&
<&lt;
>&gt;
"&quot;

This applies to expression values, list items, table cells, aggregate results, loop item properties, ternary results, built-in variable output, and macro output.

Template-authored HTML (the literal text written by the template creator) is not escaped. Only data resolved from the runtime context is escaped.

No Code Execution

NLT is a pure data resolver. It cannot:

  • Import or call Python modules
  • Execute arbitrary code (no eval, no exec)
  • Access the filesystem
  • Make network requests
  • Modify the input data context
  • Access environment variables or process state

Sandboxed Context

The renderer only accesses data explicitly provided via the constructor:

  • contact dict
  • trigger_event dict
  • additional_events dict
  • event_store (Protocol -- caller-controlled)
  • template_loader (Protocol -- caller-controlled)

There is no implicit access to global state, request context, database connections, or system resources.

Tip

Content inside {raw}...{end raw} blocks is emitted verbatim without HTML escaping. This is by design -- raw blocks are authored by template creators (trusted), not populated from user data.

Zero Dependencies

The entire engine uses only the Python standard library: re, dataclasses, datetime, urllib.parse, and typing. This eliminates supply chain risk and ensures the package installs instantly with no transitive dependencies.


Next Steps