Changelog - codesaur/template
This file documents all notable changes to the codesaur/template package.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[5.0.1] - 2026-10-01
[5.0.1]: https://github.com/codesaur-php/Template/compare/v5.0.0...v5.0.1
Fixed
|e('js') did not escape template-literal characters
- Bug: The
jsstrategy escaped quotes, backslashes, control characters and< > & /, but not the backtick`or$. A value printed inside a JS template literal (`Hello {{ name|e('js') }}`) could close the literal or inject a${...}expression. U+2028 / U+2029 (line terminators inside string literals for pre-ES2019 engines) were not escaped either. - Fix:
`->\x60,$->\x24, U+2028 ->, U+2029 ->. The replacement is now a single-passstrtr(). Output for all previously escaped characters is unchanged.
Added
testJsEscapeCoversTemplateLiteralsintests/AutoEscapeTest.php.
[5.0.0] - 2026-09-09
[5.0.0]: https://github.com/codesaur-php/Template/compare/v4.1.1...v5.0.0
Changed (BREAKING)
{{ }} now HTML-escapes by default (autoescape)
- Before: print nodes output values as-is. Every user-supplied value had to be
escaped manually with
|e, and a single forgotten|ewas an XSS hole. - Now: like Twig,
{{ }}passes string results throughhtmlspecialchars($v, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'). Values that are not escaped ("safe"):- the result of the
raw,eandescapefilters (|eis never double-escaped) Markupobjects (new class, see below)MemoryTemplate/FileTemplateobjects ({{ content }}layout injection - the nested template renders with its own autoescape setting)- macro return values (the macro body is already escaped)
int,float,bool,null
- the result of the
- The safe mark travels with the value (runtime
Markupwrapper, not a compile-time filter check). Any operation that turns a safe value into a plain string drops the mark and the result is escaped again - the same semantics as Twig:{{ v|upper|e }}escapes once,{{ v|e|upper }}and{{ v|raw ~ '!' }}escape the string result,{{ ok ? v|raw : v }}is decided per branch,{% set x = v|raw %}{{ x }}stays raw. - Filters that produce HTML or HTML-significant characters (
nl2br,json_encode,striptagswith allowed tags) return plain strings and therefore need|raw. setAutoEscape(false)restores the previous behaviour for a template instance (plain-text / e-mail templates). When disabled,rawis a no-op,ereturns a plain string and macros return plain strings, exactly as in 4.x.
Added
MemoryTemplate::setAutoEscape(bool $enabled): void/isAutoEscape(): boolMemoryTemplate::escape(string $value): string(protected) - the single HTML escaping strategy used by both autoescape and thehtmlstrategy of|e; subclasses may override it.codesaur\Template\Markup-final,\Stringablewrapper that marks an HTML string as safe. Custom filters/functions that return HTML must wrap their result:fn($v) => new Markup('<b>' . htmlspecialchars((string) $v) . '</b>').tests/AutoEscapeTest.php(40 tests) - default state, every safe rule, mark loss through filters/concat/ternary,set, macros (by name, via_self, with a raw argument), nested templates,Markup, custom filters, disabled mode,FileTemplatelayouts.- "Autoescape" section in
docs/{mn,en}/README.mdanddocs/{mn,en}/api.mdwith the safe-value table, the mark-loss table and the list of filters that need|raw.
Upgrading from 4.x
- Every
{{ }}that intentionally outputs HTML needs|raw. Typical places: CMS/record content fields, HTML stored in settings,|nl2br,|json_encodeinside<script>,|striptags('<b>'), static HTML fragments passed as strings. Existing|eusages can stay - they are not double-escaped. - Layouts that receive the inner page as a template object (
$layout->set('content', $page)) need no change:{{ content }}is safe. Passing$page->output()(a string) instead would be escaped - keep passing the object. - Custom filters/functions that return HTML must return
new Markup(...). - Non-HTML templates (plain text, e-mail bodies, generated code) call
setAutoEscape(false). - To find candidates for
|raw, grep the templates for prints of fields or filters that are known to carry HTML, e.g.:grep -rnE "\{\{[^}]*(content|html|body|description)[^}]*\}\}" --include=*.html grep -rnE "\|(nl2br|json_encode|striptags)" --include=*.htmland review each hit: add
|rawonly where the value is trusted.
Fixed
tests/EngineTest.php:testBuiltInJsonDecodeFilteruses|raw(the JSON output is otherwise escaped);testAutoescapeDisablednow disables autoescape explicitly and a newtestAutoescapeEnabledByDefaultasserts the default.
[4.1.1] - 2026-07-04
[4.1.1]: https://github.com/codesaur-php/Template/compare/v4.1.0...v4.1.1
Fixed
Expression parser left tokens unconsumed on short-circuited branches
- Bug: When the ternary (
? :), elvis (?:), null coalescing (??),and, andoroperators short-circuited, the parser pointer was not advanced past the skipped operand. Standalone expressions appeared to work (trailing text is ignored), but inside parentheses, function arguments, or array literals the leftover tokens corrupted parsing:{{ (a ? 'x' : 'y') ~ '!' }}renderedxinstead ofx!{% if (a and b) or c %}evaluated incorrectly (cwas never reached){{ max(a ? 1 : 2, 5) }}truncated the argument list and could raise aTypeError
- Fix: Branching operators now always consume both operands and select the
result afterwards (
pTernary,pNullCoalesce,pOr,pAnd). Note: operands are therefore always evaluated as well - this is safe because all accessors in the engine are null-safe and division is zero-guarded.
Unary minus
- Bug: Negative literals only worked by accident (
{{ -5 }}parsed asnull - 5), and failed at higher precedence:{{ 3 * -2 }}returned-2instead of-6. - Fix:
pPrimary()now supports a proper unary minus for numeric operands ({{ -5 }},{{ 3 * -2 }},{{ -price }}); non-numeric operands yield0.
Added
Tests
testTernaryInsideParentheses,testElvisInsideParentheses,testNullCoalescingInsideParentheses,testLogicalOperatorsInsideParentheses,testOrFollowedByTernary,testTernaryInsideFunctionArguments- parser pointer regression teststestUnaryMinus- negative literals, unary minus with multiplication and variables
Changed
- Removed the author's phone number from
composer.json.
[4.1.0] - 2026-06-19
[4.1.0]: https://github.com/codesaur-php/Template/compare/v4.0.2...v4.1.0
Added
- More Twig-compatible operators in the expression parser (
MemoryTemplate::pCompare()):in/not inmembership -{% if type in ['image', 'video'] %}. Works on arrays (in_array, loose), strings (str_containssubstring), andTraversable.ends withstring operator - complements the existingstarts with.matchesregex operator -{% if email matches '/^[^@]+@[^@]+$/' %}(usespreg_match, errors suppressed).is even/is oddnumber tests (also viais not) - e.g. alternating row styles in loops.- Note: the
for ... inloop is parsed at the tag level (buildFor), so the newincomparison operator does not conflict with it.
[4.0.2] - 2026-06-09
[4.0.2]: https://github.com/codesaur-php/Template/compare/v4.0.1...v4.0.2
Changed
- Property names no longer use a leading underscore (PSR-12 compliance)
MemoryTemplate:$_html->$html,$_vars->$varsFileTemplate:$_file->$filepath- Internal only - all properties are
protected, so the public API is unchanged.
[4.0.1] - 2026-04-27
[4.0.1]: https://github.com/codesaur-php/Template/compare/v4.0.0...v4.0.1
Fixed
{% for %}{% else %}{% endfor %} empty-iterable branch
- Bug: Templates using the Twig-style
{% for %}{% else %}{% endfor %}construct silently dropped everything after the{% endfor %}from the rendered output. The parser hit{% else %}, returned the loop body it had collected so far, but never consumed theelse/endfortokens - so the outerbuildTreesaw the orphanelseas an end-marker and returned immediately. Both theelsebranch and any content following the loop were lost. - Root cause:
buildFor()only looked for{% endfor %}and had no knowledge of the{% else %}separator. The for-node had noelseslot, andrenderFor()returned an empty string for non-iterable / empty collections instead of falling back to the alternate branch. - Fix:
buildFor()now optionally consumes an{% else %}block and attaches its body as a newelsekey on the for-node.renderFor()renders that else body when the iterable is missing, non-iterable, or empty - matching Twig's documented behavior.
Object method calls in expressions
- Bug: Expressions like
{{ user.can('edit') }}and{% if auth.is('admin') %}silently returnednull, causing permission-gated UI to be hidden even from authorized users. - Root cause: The
pPostfix()parser only handled the_self.macroName(...)(macro invocation) case when it encountered a method call with arguments, and silently set the result tonullfor all other cases - completely skipping object and array-of-callables method dispatch. - Fix:
pPostfix()now handles three distinct cases when a.name(args)postfix is parsed:_self.macro(...)->callMacro(unchanged)object.method(...)->$val->$method(...$args)(guarded bymethod_exists)array['callable'](...)-> invokes the callable array element directly- Otherwise ->
null(unchanged)
Added
Documentation
- Added "Method calls" line to the
MemoryTemplateclass docblock describing the newly supportedobject.method(args)andarray_of_callables.name(args)forms.
Tests
testForElseWithItems-{% else %}is skipped when the loop iteratestestForElseWithEmptyItems-{% else %}renders for empty arraystestForElseWithNonIterable-{% else %}renders for null / non-iterabletestObjectMethodCall- calling public methods on an object from expressionstestObjectMethodCallInIfBlock- using a method call as an{% if %}conditiontestArrayOfCallablesMethodCall- calling closures stored as array valuestestMissingMethodReturnsNull- non-existent methods safely returnnull
[4.0.0] - 2026-03-30
[4.0.0]: https://github.com/codesaur-php/Template/compare/v3.0.1...v4.0.0
Changed
Completely removed Twig dependency
- Removed
twig/twigpackage entirely from dependencies - During development, the necessary capabilities inspired by Twig's syntax and design patterns were reimplemented as our own standalone engine
- Added ext-mbstring to requirements (for capitalize, upper, lower, length, etc.)
Migrated full engine into MemoryTemplate
- The complete template engine (tokenizer, parser, renderer, expression evaluator) has been moved from FileTemplate into MemoryTemplate. MemoryTemplate now supports if/for/set/macro, filter chains, expression parser, ternary/null coalescing, and loop variables
- FileTemplate is now a thin wrapper that only reads files and passes them to MemoryTemplate's engine
New methods added to MemoryTemplate
addFilter(string, callable),addFunction(string, callable)- register custom filters/functions- 33 built-in filters (e, date, length, keys, slice, json_encode, json_decode, abs, trim, striptags, title, join, reverse, sort, unique, column, batch, values, replace, wordwrap, etc.)
- Built-in functions:
attribute,range,max,min
Removed
- Removed the TwigTemplate class (merged into FileTemplate)
twig/twigdependency (15+ files, ~50,000 lines of code removed)addGlobal(),getEnvironment()methods (Twig-specific){# comment #}template comment syntax- Removed the
stringify()protected method - replaced with(string)cast
[3.0.1] - 2026-03-05
[3.0.1]: https://github.com/codesaur-php/Template/compare/v3.0.0...v3.0.1
Changed
Documentation Cleanup
- Removed all Unicode emoji characters from documentation files
- Replaced Unicode arrow with ASCII arrow
[3.0.0] - 2026-01-08
[3.0.0]: https://github.com/codesaur-php/Template/compare/v2.0.0...v3.0.0
Stable Release
This version is the stable release of the codesaur/template package with complete features, full test coverage, and comprehensive documentation.
Added
Core Functionality
- MemoryTemplate - Lightweight template engine with simple {{key}} placeholders
- FileTemplate - File-based template loader (extends MemoryTemplate)
- TwigTemplate - Advanced renderer fully integrated with Twig engine (extends FileTemplate)
Testing
- Unit, Integration, Performance, Memory tests (70+ tests, 1200+ assertions)
- 100% line coverage, 100% method coverage
CI/CD
- GitHub Actions CI/CD pipeline (PHP 8.2)
Documentation
- README.md, API.md, REVIEW.md (Mongolian and English)
[2.0.0] - 2025-11-28
[2.0.0]: https://github.com/codesaur-php/Template/compare/v1.0...v2.0.0
Added
Core Improvements
- Enhanced template processing capabilities
- Improved error handling
- Better file template support
[1.0] - 2021-03-09
[1.0]: https://github.com/codesaur-php/Template/releases/tag/v1.0
Initial Release
- MemoryTemplate - Basic template engine with {{key}} placeholders
- FileTemplate - File-based template loader
- TwigTemplate - Twig engine integration