Skip to main content

Architecture

Internal architecture reference for contributors. Covers the file structure, parse/render pipelines, context scope chain, expression tree, and template inheritance.

File Structure

lib/
├── mylekha_html_renderer.dart # Public exports only
└── src/
├── block.dart # Block (render unit), BlockParser, BlockParserFactory
├── builtins.dart # BuiltinsModule — registers all built-in filters & tags
├── context.dart # Context, RenderContext, ParseContext, Filter, Module
├── document.dart # Document extends Block — AST root, handles extends/load
├── errors.dart # ParseException
├── expressions.dart # Expression hierarchy (11 types)
├── extensions.dart # Dart extension methods used internally
├── model.dart # Source, Root, Token, TokenType (re-exports platform impl)
├── model_io.dart # Dart IO implementation of Root (BuildPath)
├── model_web.dart # Web/stub implementation of Root
├── tag.dart # Tag interface, TagStatic, ExpressionTag, AsBlock
├── template.dart # Template — public parse/render entry point
├── buildin_tags/
│ ├── assign.dart # {% assign %}
│ ├── capture.dart # {% capture %}, {% cache %}
│ ├── comment.dart # {% comment %}
│ ├── cycle.dart # {% cycle %}
│ ├── extends.dart # {% extends %}
│ ├── filter.dart # {% filter %}
│ ├── for.dart # {% for %}
│ ├── if.dart # {% if %}, {% unless %}
│ ├── ifchanged.dart # {% ifchanged %}
│ ├── include.dart # {% include %}
│ ├── load.dart # {% load %}
│ ├── named_block.dart # {% block %}
│ └── regroup.dart # {% regroup %}
├── exception/
│ ├── parse_block_exception.dart
│ └── tag_render_exception.dart
├── parser/
│ ├── lexer.dart # Lexer — tokenizes source string
│ ├── parser.dart # Parser — builds AST from token stream
│ └── tag_parser.dart # TagParser — parses individual tag token lists
└── services/
├── localization_service.dart # LocalizationService singleton, Translations
└── plural_rule.dart # CLDR plural rules per locale

Component Map

classDiagram
class Template {
+Source source
+Document document
+parse(ParseContext, Source) Template$
+render(Context) Future~String~
}
class Context {
+Map variables
+Map~String,Filter~ filters
+Map~String,BlockParserFactory~ tags
+Map~String,Module~ modules
+Map~String,String~ blocks
+create() Context$
+push(Map) Context
+clone() Context
+cloneAsRoot() Context
}
class Document {
+render(RenderContext) Stream~String~
}
class Block {
+List~Tag~ children
+render(RenderContext) Stream~String~
}
class Tag {
<<interface>>
+render(RenderContext) Stream~String~
}
class Expression {
<<interface>>
+evaluate(RenderContext) Future~dynamic~
}
class Module {
<<interface>>
+register(Context) void
}
class Root {
<<interface>>
+resolve(String) Future~Source~
}

Template --> Context
Template --> Document
Document --|> Block
Block --> Tag
Tag <|-- ExpressionTag
Tag <|-- TagStatic
Tag <|-- Block
ExpressionTag --> Expression
Context --> Module
Context --> Root

Parse Pipeline

flowchart LR
A["Source\nstring + Root"] --> B["Lexer\ntokenize"]
B --> C["Iterator&lt;Token&gt;"]
C --> D["Parser\nparseBlock"]
D --> E{Tag type?}
E -->|raw HTML| F[TagStatic]
E -->|expression| G["ExpressionTag\n+ Expression tree"]
E -->|known tag| H["context.tags[name]\nBlockParser"]
H --> I["BlockParser.create\nreturns Block"]
I --> J["AST node\nadded to parent"]
F --> J
G --> J
J --> K["Document\nAST root"]

Key types in the parse phase:

TypeFileRole
Lexerparser/lexer.dartConverts raw string into Token stream
Tokenmodel.dartType + value + line/column
TokenTypemodel.dartEnum: identifier, string, number, pipe, comma, comparison, tag_start, tag_end, variable_start, variable_end, ...
Parserparser/parser.dartConsumes token stream, calls BlockParser per tag
TagParserparser/tag_parser.dartHelper to consume tokens within a single tag's argument list
BlockParserblock.dartPer-tag parser; create() returns the final Block
Documentdocument.dartRoot AST node

Render Pipeline

flowchart LR
A["template.render\nContext"] --> B["Document.render\nRenderContext"]
B --> C{Has extends?}
C -->|yes| D["Resolve base Document\nvia DocumentFuture"]
D --> E["Pre-render named blocks\ninto context.blocks"]
E --> F["base.render baseContext"]
C -->|no| G["Activate load modules\nregisterModule each"]
G --> H["super.render = Block.render"]
H --> I["For each Tag child"]
I --> J{Tag type?}
J -->|TagStatic| K["yield raw HTML string"]
J -->|ExpressionTag| L["Expression.evaluate\nyield result string"]
J -->|Block subclass| M["Block.render recursively\nyield* child stream"]
K --> N["collected Stream&lt;String&gt;"]
L --> N
M --> N
F --> N
N --> O["Future&lt;String&gt;\njoined result"]

Every render() is an async* generator returning Stream<String>. Errors in any tag are caught and re-thrown as TagRenderException.


Context Scope Chain

graph TD
A["Context.create()\nroot context\nvariables: globals\nfilters: all builtins\ntags: all builtins"] -->|push vars| B["Context.push(Map)\nchild context\nvariables: merged\nshares filters & tags"]
B -->|push vars| C["Context.push(Map)\ngrandchild\n(e.g. forloop context)"]
A -->|clone| D["Context.clone()\nnew root\nsame filters/tags\nempty variables"]
A -->|cloneAsRoot| E["Context.cloneAsRoot()\nnew root\nno parent\ncopies filters/tags/modules"]

Lookup order: When a variable is read, the engine walks up the parent chain until it finds the key or reaches the root. Inner scopes (e.g. {% for %}) can read outer variables but not vice versa.

Key operations:

OperationResult
context.push(map)New child with map merged in; reads fall through to parent
context.clone()Sibling context; same filters/tags; no variable inheritance
context.cloneAsRoot()Fully independent root; used for {% include only %} isolation
context.rootWalks parent chain to the original root

Expression Tree

Expressions are evaluated lazily at render time. All implement Expression.evaluate(RenderContext) → Future<dynamic>.

ClassExampleNotes
ConstantExpression"hello", 42, trueReturns the literal value
LookupExpressionmyVarLooks up context.variables['myVar']; walks parent chain
MemberExpressionorder.number, list.0Evaluates base, then accesses Map key or List index
FilterExpressionprice | multi: 2Evaluates input, calls context.filters['multi'](input, [2])
BinaryOperationa + b, x == y, a and bArithmetic, comparison, boolean
BooleanCastExpression{% if value %}Casts any value to bool: null→false, ""→false, 0→false, []→false
NotExpressionnot activeInverts a BooleanCastExpression
BlockExpression{% filter ... %}block{% endfilter %}Renders a Block to a string value
ObjectExpression{ key: val }Evaluates each entry and returns a Map
ArrayExpression[1, 2, 3]Evaluates each element and returns a List

Module System

Modules bundle filters and tags that are opt-in per template via {% load %}.

flowchart LR
A["context.modules\n'mymodule' = MyModule()"] -->|parse time| B["{% load mymodule %}\ncreates Load block"]
B -->|render time| C["Load.render\ncontext.registerModule\n'mymodule'"]
C --> D["modules['mymodule']\n.register(context)"]
D --> E["context.filters\nnew keys added"]
D --> F["context.tags\nnew keys added"]

BuiltinsModule is registered automatically by Context.create() and always active — it does not require {% load %}. All 40+ built-in filters and 14 built-in tags are registered here in lib/src/builtins.dart.


Template Inheritance

flowchart TD
A["child.html\n{% extends 'base.html' %}\n{% block title %}...{% endblock %}\n{% block body %}...{% endblock %}"] -->|parse| B["Document with\nextends ref + NamedBlock nodes"]
B -->|render: resolve base| C["base.html Document\n{% block title %}default{% endblock %}\n{% block body %}default{% endblock %}"]
B -->|pre-render child blocks| D["context.blocks\n'title' → 'Order Receipt'\n'body' → '...'"]
D --> E["base.render baseContext\n(with blocks pre-filled)"]
E --> F["NamedBlock.render\nchecks context.blocks['name']\nif found → renders override\nelse → renders default"]
F --> G["Final HTML output"]

Deep inheritance works the same way — Dart resolves each {% extends %} recursively until it finds a base template with no extends.


DocumentFuture — Lazy Template Resolution

DocumentFuture is an internal wrapper that defers template loading until render time. It is used by both {% extends %} and {% include %}.

At parse time, only the path expression is captured. At render time, resolve(context) evaluates the path expression (which may be a variable), calls root.resolve(path), parses the result, and returns the Document. This means:

  • {% extends variablePath %} with a runtime variable works correctly
  • Templates are loaded once and cached per render call
warning

If Source.root is null and {% include %} or {% extends %} is used, a ParseException.missingRoot() is thrown immediately at parse time.