├── lib ├── src │ ├── evaluator.dart │ ├── evaluator │ │ ├── visit_async.dart │ │ ├── layout.dart │ │ ├── buffer.dart │ │ └── evaluate.dart │ ├── exceptions.dart │ ├── registry.dart │ ├── mixins │ │ └── parser.dart │ ├── filters │ │ ├── module.dart │ │ ├── filters.dart │ │ └── html.dart │ ├── grammar │ │ └── grammar.dart │ ├── tag.dart │ ├── tags │ │ ├── break.dart │ │ ├── continue.dart │ │ ├── echo.dart │ │ ├── doc_tag.dart │ │ ├── super_tag.dart │ │ ├── comment.dart │ │ ├── tags.dart │ │ ├── decrement.dart │ │ ├── increment.dart │ │ ├── raw.dart │ │ ├── assign.dart │ │ ├── block.dart │ │ ├── capture.dart │ │ ├── liquid.dart │ │ ├── repeat.dart │ │ ├── unless.dart │ │ ├── case.dart │ │ ├── cycle.dart │ │ ├── if.dart │ │ ├── tag.dart │ │ ├── render.dart │ │ └── layout.dart │ ├── buffer.dart │ ├── visitor.dart │ ├── analyzer │ │ └── template_analysis.dart │ ├── filter_registry.dart │ ├── drop.dart │ ├── tag_registry.dart │ └── template.dart └── parser.dart ├── assets └── logo.png ├── .gitignore ├── .idea ├── .gitignore ├── vcs.xml ├── misc.xml ├── modules.xml └── libraries │ └── Dart_SDK.xml ├── .vscode └── launch.json ├── liquid_grammar.iml ├── test ├── shared.dart ├── shared_test_root.dart ├── issues_test.dart ├── tags │ ├── doc_tag_test.dart │ ├── comment_tag_test.dart │ ├── capture_tag_test.dart │ ├── raw_tag_test.dart │ ├── echo_tag_test.dart │ ├── dot_notation.dart │ ├── cycle_tag_test.dart │ ├── unless_tag_test.dart │ ├── assign_tag_test.dart │ ├── liquid_tag_test.dart │ ├── increment_tag_test.dart │ ├── decrement_tag_test.dart │ ├── if_tag_test.dart │ └── case_tag_test.dart ├── drop_test.dart ├── analyzer │ ├── resolver │ │ ├── simple_layout_merge_test.dart │ │ ├── super_tag_merge_test.dart │ │ ├── simple_inheritance_merge_test.dart │ │ ├── ast_matcher.dart │ │ ├── multi_level_inheritance_merge_test.dart │ │ ├── nested_merge_test.dart │ │ └── deeply_nested_super_merge_test.dart │ └── analyzer │ │ ├── layout_analyzer_test.dart │ │ ├── nested_blocks_test.dart │ │ ├── super_call_test.dart │ │ ├── multilevel_layout_test.dart │ │ └── deeply_nested_super_call_test.dart ├── template_test.dart ├── layout_test2.dart └── fs_test.dart ├── .github └── workflows │ ├── publish.yml │ └── dart.yml ├── pubspec.yaml ├── docs ├── tags │ ├── output.md │ ├── utility.md │ ├── layout.md │ └── README.md ├── examples │ └── README.md └── README.md ├── LICENSE ├── analysis_options.yaml ├── example ├── fs.dart ├── drop.dart ├── example.dart ├── custom_tag.dart └── layout.dart └── CHANGELOG.md /lib/src/evaluator.dart: -------------------------------------------------------------------------------- 1 | export 'evaluator/evaluator.dart'; 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kingwill101/liquify/HEAD/assets/logo.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # https://dart.dev/guides/libraries/private-files 2 | # Created by `dart pub` 3 | .dart_tool/ 4 | -------------------------------------------------------------------------------- /lib/src/evaluator/visit_async.dart: -------------------------------------------------------------------------------- 1 | part of 'evaluator.dart'; 2 | 3 | extension AsyncVisits on Evaluator {} 4 | -------------------------------------------------------------------------------- /lib/src/exceptions.dart: -------------------------------------------------------------------------------- 1 | class BreakException implements Exception {} 2 | 3 | class ContinueException implements Exception {} 4 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Datasource local storage ignored files 5 | /dataSources/ 6 | /dataSources.local.xml 7 | -------------------------------------------------------------------------------- /lib/src/registry.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/liquify.dart'; 2 | 3 | void registerBuiltIns() { 4 | registerBuiltInTags(); 5 | FilterRegistry.initModules(); 6 | } 7 | -------------------------------------------------------------------------------- /lib/src/mixins/parser.dart: -------------------------------------------------------------------------------- 1 | import 'package:petitparser/petitparser.dart'; 2 | 3 | mixin CustomTagParser { 4 | Parser parser() { 5 | return epsilon(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /lib/src/filters/module.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/filter_registry.dart'; 2 | 3 | class Module { 4 | Map filters = {}; 5 | void register() {} 6 | } 7 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /lib/src/grammar/grammar.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/grammar/shared.dart'; 2 | 3 | class LiquidGrammar extends GrammarDefinition { 4 | @override 5 | Parser start() => ref0(document).end(); 6 | } 7 | -------------------------------------------------------------------------------- /lib/src/evaluator/layout.dart: -------------------------------------------------------------------------------- 1 | // These are no longer needed as we build the hierarchy at once 2 | // _LayoutInfo? _extractLayoutInfo(List nodes) { ... } 3 | // Future<_LayoutInfo?> _extractLayoutInfoAsync(List nodes) async { ... } 4 | // } 5 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /lib/src/filters/filters.dart: -------------------------------------------------------------------------------- 1 | export 'array.dart' show ArrayModule; 2 | export 'date.dart' show DateModule; 3 | export 'html.dart' show HtmlModule; 4 | export 'math.dart' show MathModule; 5 | export 'misc.dart' show MiscModule; 6 | export 'string.dart' show StringModule; 7 | export 'url.dart' show UrlModule; 8 | -------------------------------------------------------------------------------- /lib/src/tag.dart: -------------------------------------------------------------------------------- 1 | export 'package:liquify/src/grammar/shared.dart'; 2 | export 'package:liquify/src/tags/tag.dart'; 3 | export 'package:liquify/src/ast.dart'; 4 | export 'package:liquify/src/buffer.dart'; 5 | export 'package:liquify/src/evaluator.dart'; 6 | export 'package:liquify/src/mixins/parser.dart'; 7 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "liquify", 9 | "request": "launch", 10 | "type": "dart", 11 | "vmAdditionalArgs": ["--enable-experiment=macros"] 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /lib/src/tags/break.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/evaluator.dart'; 2 | import 'package:liquify/src/exceptions.dart'; 3 | import 'package:liquify/src/tags/tag.dart'; 4 | import 'package:liquify/src/buffer.dart'; 5 | 6 | class BreakTag extends AbstractTag with AsyncTag { 7 | BreakTag(super.content, super.filters); 8 | 9 | @override 10 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 11 | throw BreakException(); 12 | } 13 | 14 | @override 15 | Future evaluateWithContextAsync( 16 | Evaluator evaluator, Buffer buffer) async { 17 | throw BreakException(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/tags/continue.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/evaluator.dart'; 2 | import 'package:liquify/src/exceptions.dart'; 3 | import 'package:liquify/src/tags/tag.dart'; 4 | import 'package:liquify/src/buffer.dart'; 5 | 6 | class ContinueTag extends AbstractTag with AsyncTag { 7 | ContinueTag(super.content, super.filters); 8 | 9 | @override 10 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 11 | throw ContinueException(); 12 | } 13 | 14 | @override 15 | Future evaluateWithContextAsync( 16 | Evaluator evaluator, Buffer buffer) async { 17 | throw ContinueException(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /liquid_grammar.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /lib/src/evaluator/buffer.dart: -------------------------------------------------------------------------------- 1 | part of 'evaluator.dart'; 2 | 3 | extension BufferHandling on Evaluator { 4 | Buffer get currentBuffer => 5 | _blockBuffers.isEmpty ? buffer : _blockBuffers.last; 6 | 7 | void startBlockCapture() { 8 | pushBuffer(); 9 | } 10 | 11 | String endBlockCapture() { 12 | return popBuffer(); 13 | } 14 | 15 | bool isCapturingBlock() { 16 | return _blockBuffers.isNotEmpty; 17 | } 18 | 19 | void pushBuffer() { 20 | _blockBuffers.add(Buffer()); 21 | } 22 | 23 | String popBuffer() { 24 | if (_blockBuffers.isEmpty) { 25 | throw Exception('No block buffer to pop'); 26 | } 27 | return _blockBuffers.removeLast().toString(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /test/shared.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:liquify/liquify.dart'; 5 | import 'package:liquify/parser.dart'; 6 | 7 | FutureOr testParser(String source, 8 | FutureOr Function(Document document) testFunction) async { 9 | try { 10 | final document = Document(parseInput(source)); 11 | try { 12 | await testFunction(document); 13 | } catch (e) { 14 | print('Error: $e'); 15 | printAST(document, 0); 16 | 17 | JsonEncoder encoder = JsonEncoder.withIndent(' '); 18 | final encoded = encoder.convert(document); 19 | print(encoded); 20 | 21 | rethrow; 22 | } 23 | } catch (e) { 24 | rethrow; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/publish.yml 2 | name: Publish to pub.dev 3 | 4 | on: 5 | push: 6 | tags: 7 | - 'v[0-9]+.[0-9]+.[0-9]+*' # tag pattern on pub.dev: 'v{{version}' 8 | 9 | # Publish using custom workflow 10 | jobs: 11 | publish: 12 | permissions: 13 | id-token: write # Required for authentication using OIDC 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - uses: dart-lang/setup-dart@v1 18 | - name: Install dependencies 19 | run: dart pub get 20 | # Here you can insert custom steps you need 21 | # - run: dart tool/generate-code.dart 22 | - name: Publish 23 | run: dart pub publish --force 24 | -------------------------------------------------------------------------------- /test/shared_test_root.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/fs.dart'; 2 | 3 | class TestRoot implements Root { 4 | final Map files = {}; 5 | 6 | @override 7 | Future resolveAsync(String path) async { 8 | final content = files[path]; 9 | if (content == null) throw Exception('File not found: $path'); 10 | return Source(Uri.parse(path), content, this); 11 | } 12 | 13 | @override 14 | Source resolve(String path) { 15 | final content = files[path]; 16 | if (content == null) throw Exception('File not found: $path'); 17 | return Source(Uri.parse(path), content, this); 18 | } 19 | 20 | void addFile(String path, String content) { 21 | files[path] = content; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/tags/echo.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/buffer.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:liquify/src/tags/tag.dart'; 4 | 5 | class EchoTag extends AbstractTag with AsyncTag { 6 | EchoTag(super.content, super.filters); 7 | 8 | @override 9 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 10 | var value = evaluateContent(evaluator); 11 | var filtered = applyFilters(value, evaluator); 12 | buffer.write(filtered); 13 | } 14 | 15 | @override 16 | Future evaluateWithContextAsync( 17 | Evaluator evaluator, Buffer buffer) async { 18 | var value = await evaluateContentAsync(evaluator); 19 | var filtered = await applyFiltersAsync(value, evaluator); 20 | buffer.write(filtered); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/issues_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import 'shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | test("issue #23", () async { 19 | await testParser(''' 20 | {% assign name = "hello" %} 21 | {% if name contains "ello" %} 22 | These shoes are awesome! {{name}} 23 | {% endif %} 24 | ''', (document) { 25 | evaluator.evaluateNodes(document.children); 26 | expect(evaluator.buffer.toString().trim(), 27 | equals('These shoes are awesome! hello')); 28 | }); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: liquify 2 | description: > 3 | A powerful and extensible Liquid template engine for Dart. 4 | Supports full Liquid syntax, custom tags and filters, and high-performance parsing and rendering. 5 | 6 | version: 1.3.1 7 | 8 | repository: https://github.com/kingwill101/liquify 9 | 10 | topics: 11 | - liquid 12 | - templating 13 | - teamplate-engine 14 | - dart 15 | 16 | environment: 17 | sdk: ">=3.5.0 <4.0.0" 18 | 19 | dependencies: 20 | petitparser: ^7.0.0 21 | html: ^0.15.6 22 | html_unescape: ^2.0.0 23 | intl: ^0.20.2 24 | timezone: ^0.10.1 25 | gato: ^0.0.5+1 26 | file: ^7.0.1 27 | args: ^2.7.0 28 | logging: ^1.3.0 29 | 30 | dev_dependencies: 31 | lints: ^6.0.0 32 | test: ^1.26.2 33 | 34 | funding: 35 | - https://www.buymeacoffee.com/kingwill101 36 | -------------------------------------------------------------------------------- /lib/src/tags/doc_tag.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/tag.dart'; 2 | 3 | class DocTag extends AbstractTag with CustomTagParser, AsyncTag { 4 | DocTag(super.content, super.filters); 5 | 6 | @override 7 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) {} 8 | 9 | @override 10 | Future evaluateWithContextAsync( 11 | Evaluator evaluator, Buffer buffer) async {} 12 | 13 | @override 14 | Parser parser() { 15 | return (tagStart() & 16 | string('doc').trim() & 17 | tagEnd() & 18 | any() 19 | .starLazy((tagStart() & string('enddoc').trim() & tagEnd())) 20 | .flatten() & 21 | tagStart() & 22 | string('enddoc').trim() & 23 | tagEnd()) 24 | .map((values) { 25 | return Tag("doc", [TextNode(values[3])]); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/tags/super_tag.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/parser.dart'; 2 | 3 | /// A tag that represents a call to the parent block's content. 4 | /// The super content is now handled by the analyzer and resolver. 5 | class SuperTag extends AbstractTag with CustomTagParser { 6 | SuperTag(super.content, super.filters); 7 | 8 | @override 9 | dynamic evaluateContent(Evaluator evaluator) { 10 | // Super content is now handled by the analyzer and resolver 11 | // This tag is only used for parsing and AST construction 12 | return ''; 13 | } 14 | 15 | @override 16 | Parser parser() { 17 | // This matches syntax: {{ super() }} 18 | return (varStart() & 19 | string('super').trim() & 20 | char('(').trim() & 21 | char(')').trim() & 22 | varEnd()) 23 | .map((_) { 24 | return Tag('super', []); 25 | }); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/src/tags/comment.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/tag.dart'; 2 | 3 | class CommentTag extends AbstractTag with CustomTagParser, AsyncTag { 4 | CommentTag(super.content, super.filters); 5 | 6 | @override 7 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) {} 8 | 9 | @override 10 | Future evaluateWithContextAsync( 11 | Evaluator evaluator, Buffer buffer) async {} 12 | 13 | @override 14 | Parser parser() { 15 | return (tagStart() & 16 | string('comment').trim() & 17 | tagEnd() & 18 | any() 19 | .starLazy((tagStart() & string('endcomment').trim() & tagEnd())) 20 | .flatten() & 21 | tagStart() & 22 | string('endcomment').trim() & 23 | tagEnd()) 24 | .map((values) { 25 | return Tag("comment", [TextNode(values[3])]); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/tags/tags.dart: -------------------------------------------------------------------------------- 1 | export 'break.dart' show BreakTag; 2 | export 'continue.dart' show ContinueTag; 3 | export 'case.dart' show CaseTag; 4 | export 'echo.dart' show EchoTag; 5 | export 'for.dart' show ForTag; 6 | export 'if.dart' show IfTag; 7 | export 'cycle.dart' show CycleTag; 8 | export 'repeat.dart' show RepeatTag; 9 | export 'unless.dart' show UnlessTag; 10 | export 'tablerow.dart' show TableRowTag; 11 | export 'assign.dart' show AssignTag; 12 | export 'capture.dart' show CaptureTag; 13 | export 'liquid.dart' show LiquidTag; 14 | export 'raw.dart' show RawTag; 15 | export 'render.dart' show RenderTag; 16 | export 'increment.dart' show IncrementTag; 17 | export 'decrement.dart' show DecrementTag; 18 | export 'block.dart' show BlockTag; 19 | export 'layout.dart' show LayoutTag; 20 | export 'super_tag.dart' show SuperTag; 21 | export 'comment.dart' show CommentTag; 22 | export 'doc_tag.dart' show DocTag; 23 | -------------------------------------------------------------------------------- /.github/workflows/dart.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: Dart 7 | 8 | on: 9 | push: 10 | branches: [ "master" ] 11 | pull_request: 12 | branches: [ "master" ] 13 | 14 | jobs: 15 | test: 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, macos-latest, windows-latest] 20 | sdk: [stable, beta] 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: dart-lang/setup-dart@v1 24 | with: 25 | sdk: ${{ matrix.sdk }} 26 | 27 | - name: Install dependencies 28 | run: dart pub get 29 | 30 | - run: dart format --output=none --set-exit-if-changed . 31 | 32 | - run: dart analyze 33 | 34 | - name: Run tests 35 | run: dart test -------------------------------------------------------------------------------- /docs/tags/output.md: -------------------------------------------------------------------------------- 1 | # Output Tags 2 | 3 | Output tags control how content is rendered and processed in your Liquid templates. 4 | 5 | ## echo 6 | 7 | Outputs the result of an expression, equivalent to using `{{ }}` syntax. 8 | 9 | ### Syntax 10 | ```liquid 11 | {% echo expression %} 12 | {% echo expression | filter %} 13 | ``` 14 | 15 | ### Examples 16 | ```liquid 17 | {% echo "Hello World" %} 18 | 19 | 20 | {% echo user.name | upcase %} 21 | 22 | ``` 23 | 24 | --- 25 | 26 | ## liquid 27 | 28 | Allows for compact, inline Liquid syntax without the need for separate tag blocks. 29 | 30 | ### Syntax 31 | ```liquid 32 | {% liquid 33 | statement1 34 | statement2 35 | echo result 36 | %} 37 | ``` 38 | 39 | ### Examples 40 | ```liquid 41 | {% liquid 42 | assign name = "John" 43 | assign greeting = "Hello " | append: name 44 | echo greeting 45 | %} 46 | 47 | ``` 48 | 49 | ### Notes 50 | - More compact than multiple separate tag blocks 51 | - Useful for complex variable manipulation 52 | - Each line is treated as a separate Liquid statement -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Glenford Williams 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/src/buffer.dart: -------------------------------------------------------------------------------- 1 | class Buffer { 2 | final StringBuffer _buffer = StringBuffer(); 3 | 4 | /// Writes the given object to the buffer. 5 | /// 6 | /// If the object is null, an empty string is written. 7 | void write(Object? obj) { 8 | if (obj == null) { 9 | _buffer.write(''); 10 | } else { 11 | _buffer.write(obj.toString()); 12 | } 13 | } 14 | 15 | /// Writes the given object to the buffer, followed by a newline. 16 | /// 17 | /// If no object is provided, only a newline is written. 18 | void writeln([Object? obj]) { 19 | write(obj); 20 | _buffer.writeln(); 21 | } 22 | 23 | /// Returns the contents of the buffer as a string. 24 | @override 25 | String toString() => _buffer.toString(); 26 | 27 | /// Clears the contents of the buffer. 28 | void clear() { 29 | _buffer.clear(); 30 | } 31 | 32 | /// Returns the length of the buffer's contents. 33 | int get length => _buffer.length; 34 | 35 | /// Returns true if the buffer is empty. 36 | bool get isEmpty => _buffer.isEmpty; 37 | 38 | /// Returns true if the buffer is not empty. 39 | bool get isNotEmpty => _buffer.isNotEmpty; 40 | } 41 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | include: package:lints/recommended.yaml 15 | 16 | # Uncomment the following section to specify additional rules. 17 | 18 | # linter: 19 | # rules: 20 | # - camel_case_types 21 | 22 | # analyzer: 23 | 24 | # exclude: 25 | # - path/to/excluded/files/** 26 | 27 | # For more information about the core and recommended set of lints, see 28 | # https://dart.dev/go/core-lints 29 | 30 | # For additional information about configuring this file, see 31 | # https://dart.dev/guides/language/analysis-options 32 | -------------------------------------------------------------------------------- /lib/src/tags/decrement.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/tag.dart'; 2 | 3 | class DecrementTag extends AbstractTag with AsyncTag { 4 | DecrementTag(super.content, super.filters); 5 | 6 | @override 7 | void preprocess(Evaluator evaluator) { 8 | if (content.isEmpty || content.first is! Identifier) { 9 | throw Exception('DecrementTag requires a variable name as argument.'); 10 | } 11 | } 12 | 13 | String _getStateKey() { 14 | return 'counter:${(content.first as Identifier).name}'; 15 | } 16 | 17 | Future _evaluateDecrement(Evaluator evaluator, Buffer buffer, 18 | {bool isAsync = false}) async { 19 | final stateKey = _getStateKey(); 20 | final currentValue = evaluator.context.getVariable(stateKey) ?? 0; 21 | final newValue = currentValue - 1; 22 | 23 | buffer.write(newValue); 24 | evaluator.context.setVariable(stateKey, newValue); 25 | } 26 | 27 | @override 28 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 29 | return _evaluateDecrement(evaluator, buffer, isAsync: false); 30 | } 31 | 32 | @override 33 | Future evaluateWithContextAsync( 34 | Evaluator evaluator, Buffer buffer) async { 35 | return _evaluateDecrement(evaluator, buffer, isAsync: true); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/src/tags/increment.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/tag.dart'; 2 | 3 | class IncrementTag extends AbstractTag with AsyncTag { 4 | IncrementTag(super.content, super.filters); 5 | 6 | @override 7 | void preprocess(Evaluator evaluator) { 8 | if (content.isEmpty || content.first is! Identifier) { 9 | throw Exception('IncrementTag requires a variable name as argument.'); 10 | } 11 | } 12 | 13 | String _getStateKey() { 14 | return 'counter:${(content.first as Identifier).name}'; 15 | } 16 | 17 | Future _evaluateIncrement(Evaluator evaluator, Buffer buffer, 18 | {bool isAsync = false}) async { 19 | final stateKey = _getStateKey(); 20 | final currentValue = evaluator.context.getVariable(stateKey) ?? -1; 21 | final newValue = currentValue + 1; 22 | 23 | buffer.write(newValue); 24 | evaluator.context.setVariable(stateKey, newValue); 25 | } 26 | 27 | @override 28 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 29 | return _evaluateIncrement(evaluator, buffer, isAsync: false); 30 | } 31 | 32 | @override 33 | Future evaluateWithContextAsync( 34 | Evaluator evaluator, Buffer buffer) async { 35 | return _evaluateIncrement(evaluator, buffer, isAsync: true); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/src/tags/raw.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/tag.dart'; 2 | 3 | class RawTag extends AbstractTag with CustomTagParser, AsyncTag { 4 | RawTag(super.content, super.filters); 5 | 6 | Future _evaluateRaw(Evaluator evaluator, Buffer buffer, 7 | {bool isAsync = false}) async { 8 | for (final node in content) { 9 | if (node is TextNode) { 10 | final value = isAsync 11 | ? await evaluator.evaluateAsync(node) 12 | : evaluator.evaluate(node); 13 | buffer.write(value); 14 | } 15 | } 16 | } 17 | 18 | @override 19 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 20 | return _evaluateRaw(evaluator, buffer, isAsync: false); 21 | } 22 | 23 | @override 24 | Future evaluateWithContextAsync( 25 | Evaluator evaluator, Buffer buffer) async { 26 | return _evaluateRaw(evaluator, buffer, isAsync: true); 27 | } 28 | 29 | @override 30 | Parser parser() { 31 | return (tagStart() & 32 | string('raw').trim() & 33 | tagEnd() & 34 | any() 35 | .starLazy((tagStart() & string('endraw').trim() & tagEnd())) 36 | .flatten() & 37 | tagStart() & 38 | string('endraw').trim() & 39 | tagEnd()) 40 | .map((values) { 41 | return Tag("raw", [TextNode(values[3])]); 42 | }); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /lib/src/tags/assign.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:liquify/src/ast.dart'; 4 | import 'package:liquify/src/buffer.dart'; 5 | import 'package:liquify/src/evaluator.dart'; 6 | import 'package:liquify/src/tags/tag.dart'; 7 | 8 | class AssignTag extends AbstractTag with AsyncTag { 9 | AssignTag(super.content, super.filters); 10 | 11 | Assignment? get assignment => content.whereType().firstOrNull; 12 | 13 | bool assignmentIsVariable() => assignment?.variable is Identifier; 14 | 15 | Identifier get variable => assignment?.variable as Identifier; 16 | 17 | Future _evaluateAssignment(Evaluator evaluator, 18 | {bool isAsync = false}) async { 19 | if (assignmentIsVariable()) { 20 | final value = isAsync 21 | ? await evaluator.evaluateAsync(assignment!.value) 22 | : evaluator.evaluate(assignment!.value); 23 | _setVar(evaluator, value); 24 | } 25 | } 26 | 27 | @override 28 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 29 | return _evaluateAssignment(evaluator, isAsync: false); 30 | } 31 | 32 | @override 33 | Future evaluateWithContextAsync( 34 | Evaluator evaluator, Buffer buffer) async { 35 | return _evaluateAssignment(evaluator, isAsync: true); 36 | } 37 | 38 | void _setVar(Evaluator e, dynamic value) { 39 | e.context.setVariable(variable.name, value); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /lib/src/tags/block.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/parser.dart'; 2 | 3 | /// A tag that defines a block in a template. Used with layout inheritance. 4 | /// The block content is now handled by the analyzer and resolver. 5 | class BlockTag extends AbstractTag with CustomTagParser { 6 | late String name; 7 | 8 | BlockTag(super.content, super.filters); 9 | 10 | @override 11 | void preprocess(Evaluator evaluator) { 12 | if (content.isEmpty || content.first is! Identifier) { 13 | throw Exception('BlockTag requires a name as first argument'); 14 | } 15 | name = (content.first as Identifier).name; 16 | } 17 | 18 | @override 19 | dynamic evaluateContent(Evaluator evaluator) { 20 | // Block content is now handled by the analyzer and resolver 21 | // This tag is only used for parsing and AST construction 22 | return ''; 23 | } 24 | 25 | @override 26 | Parser parser() { 27 | return ((tagStart() & 28 | string('block').trim() & 29 | ref0(identifier).trim() & 30 | tagEnd()) & 31 | ref0(element) 32 | .starLazy(tagStart() & string('endblock').trim() & tagEnd()) & 33 | (tagStart() & string('endblock').trim() & tagEnd())) 34 | .map((values) { 35 | final tag = 36 | Tag('block', [values[2] as ASTNode], body: values[4].cast()); 37 | return tag; 38 | }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /test/tags/doc_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('Doc Tag', () { 19 | group('sync evaluation', () { 20 | test('shows raw text', () async { 21 | await testParser('''{% doc %} 22 | Renders a message. 23 | 24 | @param {string} foo - A string value. 25 | @param {string} [bar] - An optional string value. 26 | 27 | @example 28 | {% render 'message', foo: 'Hello', bar: 'World' %} 29 | {% enddoc %} 30 | ''', (document) { 31 | evaluator.evaluateNodes(document.children); 32 | expect(evaluator.buffer.toString().trim(), isEmpty); 33 | }); 34 | }); 35 | }); 36 | 37 | group('async evaluation', () { 38 | test('shows raw text', () async { 39 | await testParser('''{% doc %} 40 | Renders a message. 41 | 42 | @param {string} foo - A string value. 43 | @param {string} [bar] - An optional string value. 44 | 45 | @example 46 | {% render 'message', foo: 'Hello', bar: 'World' %} 47 | {% enddoc %} 48 | ''', (document) async { 49 | await evaluator.evaluateNodesAsync(document.children); 50 | expect(evaluator.buffer.toString().trim(), isEmpty); 51 | }); 52 | }); 53 | }); 54 | }); 55 | } 56 | -------------------------------------------------------------------------------- /test/tags/comment_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('Comment Tag', () { 19 | group('sync evaluation', () { 20 | test('shows raw text', () async { 21 | await testParser('''{% comment %} 22 | Navigation Component 23 | 24 | Usage: 25 | {% render 'components/navigation' %} 26 | {% render 'components/navigation', current_page: 'posts' %} 27 | 28 | Parameters: 29 | - current_page: Optional current page for highlighting active nav items 30 | {% endcomment %} 31 | ''', (document) { 32 | evaluator.evaluateNodes(document.children); 33 | expect(evaluator.buffer.toString().trim(), isEmpty); 34 | }); 35 | }); 36 | }); 37 | 38 | group('async evaluation', () { 39 | test('shows raw text', () async { 40 | await testParser('''{% comment %} 41 | Navigation Component 42 | 43 | Usage: 44 | {% render 'components/navigation' %} 45 | {% render 'components/navigation', current_page: 'posts' %} 46 | 47 | Parameters: 48 | - current_page: Optional current page for highlighting active nav items 49 | {% endcomment %} 50 | ''', (document) async { 51 | await evaluator.evaluateNodesAsync(document.children); 52 | expect(evaluator.buffer.toString().trim(), isEmpty); 53 | }); 54 | }); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /lib/src/evaluator/evaluate.dart: -------------------------------------------------------------------------------- 1 | part of 'evaluator.dart'; 2 | 3 | extension Evaluation on Evaluator { 4 | dynamic evaluate(ASTNode node) { 5 | return node.accept(this); 6 | } 7 | 8 | Future evaluateAsync(ASTNode node) { 9 | return node.acceptAsync(this); 10 | } 11 | 12 | List resolveAndParseTemplate(String templateName) { 13 | final root = context.getRoot(); 14 | if (root == null) { 15 | throw Exception('No root directory set for template resolution'); 16 | } 17 | 18 | final source = root.resolve(templateName); 19 | return parseInput(source.content); 20 | } 21 | 22 | Future> resolveAndParseTemplateAsync( 23 | String templateName) async { 24 | final root = context.getRoot(); 25 | if (root == null) { 26 | throw Exception('No root directory set for template resolution'); 27 | } 28 | final source = await root.resolveAsync(templateName); 29 | return parseInput(source.content); 30 | } 31 | 32 | String evaluateNodes(List nodes) { 33 | for (final node in nodes) { 34 | if (node is Assignment) continue; 35 | if (node is Tag) { 36 | node.accept(this); 37 | } else { 38 | currentBuffer.write(node.accept(this)); 39 | } 40 | } 41 | return buffer.toString(); 42 | } 43 | 44 | Future evaluateNodesAsync(List nodes) async { 45 | for (final node in nodes) { 46 | if (node is Assignment) continue; 47 | if (node is Tag) { 48 | await node.acceptAsync(this); 49 | } else { 50 | currentBuffer.write(await node.acceptAsync(this)); 51 | } 52 | } 53 | return buffer.toString(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/tags/capture.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/tag.dart'; 2 | 3 | class CaptureTag extends AbstractTag with CustomTagParser, AsyncTag { 4 | late String variableName; 5 | 6 | CaptureTag(super.content, super.filters); 7 | 8 | @override 9 | void preprocess(Evaluator evaluator) { 10 | if (content.isEmpty || content.first is! Identifier) { 11 | throw Exception( 12 | 'CaptureTag requires a variable name as the first argument.'); 13 | } 14 | variableName = (content.first as Identifier).name; 15 | } 16 | 17 | Future _evaluateCapture(Evaluator evaluator, Buffer buffer, 18 | {bool isAsync = false}) async { 19 | Buffer buf = Buffer(); 20 | final variable = args.firstOrNull; 21 | if (variable == null) { 22 | return ''; 23 | } 24 | 25 | for (final node in body) { 26 | if (node is Tag) continue; 27 | final value = isAsync 28 | ? await evaluator.evaluateAsync(node) 29 | : evaluator.evaluate(node); 30 | buf.write(value); 31 | } 32 | evaluator.context.setVariable(variable.name, buf.toString()); 33 | } 34 | 35 | @override 36 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 37 | return _evaluateCapture(evaluator, buffer, isAsync: false); 38 | } 39 | 40 | @override 41 | Future evaluateWithContextAsync( 42 | Evaluator evaluator, Buffer buffer) async { 43 | return _evaluateCapture(evaluator, buffer, isAsync: true); 44 | } 45 | 46 | @override 47 | Parser parser() { 48 | return seq3(tagStart() & string('capture').trim(), ref0(identifier).trim(), 49 | tagEnd()) 50 | .map((values) { 51 | return Tag('capture', [values.$2 as ASTNode]); 52 | }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/src/visitor.dart: -------------------------------------------------------------------------------- 1 | import 'ast.dart'; 2 | 3 | abstract class ASTVisitor { 4 | T visitDocument(Document node); 5 | 6 | T visitTag(Tag node); 7 | 8 | T visitLiteral(Literal node); 9 | 10 | T visitIdentifier(Identifier node); 11 | 12 | T visitBinaryOperation(BinaryOperation node); 13 | 14 | T visitUnaryOperation(UnaryOperation node); 15 | 16 | T visitGroupedExpression(GroupedExpression node); 17 | 18 | T visitAssignment(Assignment node); 19 | 20 | T visitTextNode(TextNode node); 21 | 22 | T visitFilterExpression(FilteredExpression node); 23 | 24 | T visitVariable(Variable node); 25 | 26 | T visitFilter(Filter node); 27 | 28 | T visitMemberAccess(MemberAccess node); 29 | 30 | T visitNamedArgument(NamedArgument node); 31 | 32 | T visitArrayAccess(ArrayAccess arrayAccess); 33 | 34 | // Asynchronous methods 35 | Future visitDocumentAsync(Document node); 36 | 37 | Future visitTagAsync(Tag node); 38 | 39 | Future visitLiteralAsync(Literal node); 40 | 41 | Future visitIdentifierAsync(Identifier node); 42 | 43 | Future visitBinaryOperationAsync(BinaryOperation node); 44 | 45 | Future visitUnaryOperationAsync(UnaryOperation node); 46 | 47 | Future visitGroupedExpressionAsync(GroupedExpression node); 48 | 49 | Future visitAssignmentAsync(Assignment node); 50 | 51 | Future visitTextNodeAsync(TextNode node); 52 | 53 | Future visitFilterExpressionAsync(FilteredExpression node); 54 | 55 | Future visitVariableAsync(Variable node); 56 | 57 | Future visitFilterAsync(Filter node); 58 | 59 | Future visitMemberAccessAsync(MemberAccess node); 60 | 61 | Future visitNamedArgumentAsync(NamedArgument node); 62 | 63 | Future visitArrayAccessAsync(ArrayAccess arrayAccess); 64 | } 65 | -------------------------------------------------------------------------------- /test/drop_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/liquify.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | Map data = { 6 | 'name': PersonDrop(firstName: "John", lastName: "Jones"), 7 | }; 8 | 9 | group('Drop', () { 10 | test('properties', () async { 11 | expect(Template.parse('{{ name.lastName }}', data: data).render(), 12 | equals('Jones')); 13 | }); 14 | 15 | test('nesting', () async { 16 | var template = 17 | Template.parse('{{ name.address.country }}', data: data).render(); 18 | expect(template, equals('U.S.A')); 19 | }); 20 | 21 | test('invokable', () async { 22 | expect( 23 | Template.parse('{{ name.first }} {{ name.last }}', data: data) 24 | .render(), 25 | equals('John Jones')); 26 | }); 27 | }); 28 | } 29 | 30 | class AddressDrop extends Drop { 31 | @override 32 | Map get attrs => {"country": "U.S.A"}; 33 | } 34 | 35 | class PersonDrop extends Drop { 36 | String firstName; 37 | String lastName; 38 | 39 | PersonDrop({required this.firstName, required this.lastName}); 40 | 41 | String fullName() { 42 | return '$firstName $lastName'; 43 | } 44 | 45 | @override 46 | Map get attrs => { 47 | "firstName": firstName, 48 | "lastName": lastName, 49 | "fullName": fullName(), 50 | "address": AddressDrop(), 51 | }; 52 | 53 | @override 54 | List get invokable => [ 55 | ...super.invokable, 56 | #first, 57 | #last, 58 | ]; 59 | 60 | @override 61 | invoke(Symbol symbol) { 62 | switch (symbol) { 63 | case #first: 64 | return firstName; 65 | case #last: 66 | return lastName; 67 | default: 68 | return liquidMethodMissing(symbol); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /test/analyzer/resolver/simple_layout_merge_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 2 | import 'package:liquify/src/analyzer/resolver.dart'; 3 | import 'package:liquify/src/util.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import '../../shared_test_root.dart'; 7 | import 'ast_matcher.dart'; 8 | 9 | void main() { 10 | group('Simple Layout Merge', () { 11 | late TestRoot root; 12 | late TemplateAnalyzer analyzer; 13 | 14 | setUp(() { 15 | // Configure logging - enable only resolver logs 16 | Logger.disableAllContexts(); 17 | // Logger.enableContext('Resolver'); 18 | root = TestRoot(); 19 | analyzer = TemplateAnalyzer(root); 20 | 21 | // Parent template defines a basic layout with two blocks. 22 | root.addFile('parent.liquid', ''' 23 | 24 | 25 | 26 | {% block title %}Default Title{% endblock %} 27 | 28 | 29 | {% block content %}Default Content{% endblock %} 30 | 31 | 32 | '''); 33 | 34 | // Child template extends parent.liquid and overrides both blocks. 35 | root.addFile('child.liquid', ''' 36 | {% layout 'parent.liquid' %} 37 | {% block title %}Overridden Title{% endblock %} 38 | {% block content %}Overridden Content{% endblock %} 39 | '''); 40 | }); 41 | 42 | test('merges single-level inheritance correctly', () async { 43 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 44 | final structure = analysis.structures['child.liquid']!; 45 | final mergedAst = buildCompleteMergedAst(structure); 46 | 47 | ASTMatcher.validateAST(mergedAst, [ 48 | ASTMatcher.text(''), 49 | ASTMatcher.text('Overridden Title'), 50 | ASTMatcher.text('Overridden Content') 51 | ]); 52 | }); 53 | }); 54 | } 55 | -------------------------------------------------------------------------------- /example/fs.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/liquify.dart'; 2 | 3 | void main() { 4 | // Create a simple file system structure using MapRoot 5 | final fs = MapRoot({ 6 | 'resume.liquid': ''' 7 | Name: {{ name }} 8 | Skills: {{ skills | join: ", " }} 9 | {% render 'greeting.liquid' with name: name, greeting: "Welcome" %} 10 | Experience: 11 | {% render 'list.liquid' with items: experience %} 12 | ''', 13 | 'greeting.liquid': '{{ greeting }}, {{ name }}!', 14 | 'list.liquid': '{% for item in items %}- {{ item }}\n{% endfor %}', 15 | }); 16 | 17 | // Create a context with some variables 18 | final context = { 19 | 'name': 'Alice Johnson', 20 | 'skills': ['Dart', 'Flutter', 'Liquid'], 21 | 'experience': [ 22 | '5 years as a Software Developer', 23 | '3 years of Flutter development', 24 | '2 years of Dart programming' 25 | ], 26 | }; 27 | 28 | // Example 1: Render resume (which includes greeting and list) 29 | print('Example 1: Render resume (including greeting and list)'); 30 | final resumeTemplate = Template.fromFile('resume.liquid', fs, data: context); 31 | print(resumeTemplate.render()); 32 | 33 | // Example 2: Render greeting directly 34 | print('\nExample 2: Render greeting directly'); 35 | final greetingTemplate = Template.fromFile('greeting.liquid', fs, 36 | data: {'name': 'Bob', 'greeting': 'Good morning'}); 37 | print(greetingTemplate.render()); 38 | 39 | // Example 3: Render list directly 40 | print('\nExample 3: Render list directly'); 41 | final listTemplate = Template.fromFile('list.liquid', fs, data: { 42 | 'items': ['Item 1', 'Item 2', 'Item 3'] 43 | }); 44 | print(listTemplate.render()); 45 | 46 | // Example 4: Attempt to render non-existent file 47 | print('\nExample 4: Attempt to render non-existent file'); 48 | try { 49 | Template.fromFile('nonexistent.liquid', fs, data: context); 50 | } catch (e) { 51 | print('Error: $e'); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.idea/libraries/Dart_SDK.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /lib/src/tags/liquid.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/tag.dart'; 2 | import 'package:liquify/src/tag_registry.dart'; 3 | 4 | class LiquidTag extends AbstractTag with CustomTagParser, AsyncTag { 5 | LiquidTag(super.content, super.filters); 6 | 7 | Future _evaluateLiquid(Evaluator evaluator, 8 | {bool isAsync = false}) async { 9 | Evaluator innerEvaluator = evaluator.createInnerEvaluator() 10 | ..context.setRoot(evaluator.context.getRoot()); 11 | 12 | if (isAsync) { 13 | await innerEvaluator.evaluateNodesAsync(content); 14 | } else { 15 | innerEvaluator.evaluateNodes(content); 16 | } 17 | 18 | evaluator.context.merge(innerEvaluator.context.all()); 19 | return null; 20 | } 21 | 22 | @override 23 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 24 | return _evaluateLiquid(evaluator, isAsync: false); 25 | } 26 | 27 | @override 28 | Future evaluateWithContextAsync( 29 | Evaluator evaluator, Buffer buffer) async { 30 | return _evaluateLiquid(evaluator, isAsync: true); 31 | } 32 | 33 | @override 34 | Parser parser() => (tagStart() & 35 | string('liquid').trim() & 36 | any().starLazy(tagEnd()).flatten() & 37 | tagEnd()) 38 | .map((values) { 39 | return Tag("liquid", liquidTagContents(values[2], TagRegistry.tags)); 40 | }); 41 | 42 | List liquidTagContents(String content, List tagRegistry) { 43 | final lines = content.split('\n').map((line) => line.trim()).toList(); 44 | StringBuffer buffer = StringBuffer(); 45 | for (var line in lines) { 46 | if (line.startsWith('{#')) { 47 | continue; 48 | } 49 | final firstWord = line.split(' ').first; 50 | 51 | if (tagRegistry.contains(firstWord)) { 52 | buffer.writeln("{% $line %}"); 53 | } else { 54 | buffer.writeln(line); 55 | } 56 | } 57 | 58 | return parseInput(buffer.toString()); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /test/analyzer/resolver/super_tag_merge_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 2 | import 'package:liquify/src/analyzer/resolver.dart'; 3 | import 'package:liquify/src/util.dart'; 4 | import 'package:test/test.dart'; 5 | import '../../shared_test_root.dart'; 6 | import 'ast_matcher.dart'; 7 | 8 | void main() { 9 | group('Super Tag Merge', () { 10 | late TestRoot root; 11 | late TemplateAnalyzer analyzer; 12 | 13 | setUp(() { 14 | // Configure logging - enable only resolver logs 15 | Logger.disableAllContexts(); 16 | // // Logger.enableContext('Resolver'); 17 | root = TestRoot(); 18 | analyzer = TemplateAnalyzer(root); 19 | 20 | // Parent template: defines a content block. 21 | root.addFile('parent.liquid', ''' 22 | 23 | 24 | 25 | {% block content %} 26 |

Parent Content

27 | {% endblock %} 28 | 29 | 30 | '''); 31 | 32 | // Child template: overrides content block and calls super(). 33 | root.addFile('child.liquid', ''' 34 | {% layout 'parent.liquid' %} 35 | {% block content %} 36 |
Child Before
37 | {{ super() }} 38 |
Child After
39 | {% endblock %} 40 | '''); 41 | }); 42 | 43 | test('merged AST reflects super() call override', () async { 44 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 45 | final structure = analysis.structures['child.liquid']!; 46 | final mergedAst = buildCompleteMergedAst(structure); 47 | 48 | ASTMatcher.validateAST(mergedAst, [ 49 | ASTMatcher.text('Child Before'), 50 | // In this scenario, parent's content is "

Parent Content

" 51 | ASTMatcher.text('Parent Content'), 52 | ASTMatcher.text('Child After'), 53 | // The merged AST should not include a literal "super" but have replaced it. 54 | ]); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /test/analyzer/resolver/simple_inheritance_merge_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 2 | import 'package:liquify/src/analyzer/resolver.dart'; 3 | import 'package:liquify/src/util.dart'; 4 | import 'package:test/test.dart'; 5 | import '../../shared_test_root.dart'; 6 | 7 | void main() { 8 | group('Simple Inheritance Merge', () { 9 | late TestRoot root; 10 | late TemplateAnalyzer analyzer; 11 | 12 | setUp(() { 13 | // Configure logging - enable only resolver logs 14 | Logger.disableAllContexts(); 15 | // Logger.enableContext('Resolver'); 16 | root = TestRoot(); 17 | analyzer = TemplateAnalyzer(root); 18 | 19 | // Parent template: a basic layout with two blocks. 20 | root.addFile('parent.liquid', ''' 21 | 22 | 23 | 24 | {% block title %}Default Title{% endblock %} 25 | 26 | 27 | {% block content %}Default Content{% endblock %} 28 | 29 | 30 | '''); 31 | 32 | // Child template: extends parent.liquid and overrides both blocks. 33 | root.addFile('child.liquid', ''' 34 | {% layout 'parent.liquid' %} 35 | {% block title %}Overridden Title{% endblock %} 36 | {% block content %}Overridden Content{% endblock %} 37 | '''); 38 | }); 39 | 40 | test('merged AST contains full layout with overridden blocks', () async { 41 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 42 | final structure = analysis.structures['child.liquid']!; 43 | 44 | // Build the complete merged AST (i.e. parent's raw AST with child overrides applied) 45 | final mergedAst = buildCompleteMergedAst(structure); 46 | final mergedText = mergedAst.map((node) => node.toString()).join(); 47 | 48 | expect(mergedText, contains('')); 49 | expect(mergedText, contains('Overridden Title')); 50 | expect(mergedText, contains('Overridden Content')); 51 | }); 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /test/tags/capture_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | group('Capture Tag', () { 18 | group('sync evaluation', () { 19 | test('outputs captured data', () async { 20 | await testParser( 21 | '{% capture my_variable %}I am being captured.{% endcapture %}{{ my_variable }}', 22 | (document) { 23 | evaluator.evaluateNodes(document.children); 24 | expect(evaluator.buffer.toString(), 'I am being captured.'); 25 | }); 26 | }); 27 | 28 | test('captures with filters', () async { 29 | await testParser( 30 | '{% capture my_variable %}Hello {{ "World" | upcase }}{% endcapture %}{{ my_variable }}', 31 | (document) { 32 | evaluator.evaluateNodes(document.children); 33 | expect(evaluator.buffer.toString(), 'Hello WORLD'); 34 | }); 35 | }); 36 | }); 37 | 38 | group('async evaluation', () { 39 | test('outputs captured data', () async { 40 | await testParser( 41 | '{% capture my_variable %}I am being captured.{% endcapture %}{{ my_variable }}', 42 | (document) async { 43 | await evaluator.evaluateNodesAsync(document.children); 44 | expect(evaluator.buffer.toString(), 'I am being captured.'); 45 | }); 46 | }); 47 | 48 | test('captures with filters', () async { 49 | await testParser( 50 | '{% capture my_variable %}Hello {{ "World" | upcase }}{% endcapture %}{{ my_variable }}', 51 | (document) async { 52 | await evaluator.evaluateNodesAsync(document.children); 53 | expect(evaluator.buffer.toString(), 'Hello WORLD'); 54 | }); 55 | }); 56 | }); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /test/template_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/template.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Template', () { 6 | test('sync render', () { 7 | final template = 8 | Template.parse('Hello {{ name }}!', data: {'name': 'World'}); 9 | expect(template.render(), equals('Hello World!')); 10 | }); 11 | 12 | test('async render', () async { 13 | final template = 14 | Template.parse('Hello {{ name }}!', data: {'name': 'World'}); 15 | expect(await template.renderAsync(), equals('Hello World!')); 16 | }); 17 | 18 | test('async render with complex template', () async { 19 | final template = Template.parse(''' 20 | {% for item in items %} 21 | {% if item > 2 %} 22 | {{ item }} 23 | {% endif %} 24 | {% endfor %} 25 | ''', data: { 26 | 'items': [1, 2, 3, 4, 5] 27 | }); 28 | 29 | final result = await template.renderAsync(); 30 | expect(result.replaceAll(RegExp(r'\s+'), ' ').trim(), '3 4 5'); 31 | }); 32 | 33 | test('buffer clearing', () async { 34 | final template = Template.parse('{{ value }}', data: {'value': 'test'}); 35 | 36 | // First render 37 | expect(await template.renderAsync(clearBuffer: false), equals('test')); 38 | 39 | // Second render should append to buffer if not cleared 40 | expect( 41 | await template.renderAsync(clearBuffer: false), equals('testtest')); 42 | 43 | // Third render should start fresh with cleared buffer 44 | expect(await template.renderAsync(clearBuffer: true), equals('test')); 45 | }); 46 | 47 | test('context updates between renders', () async { 48 | final template = Template.parse('{{ greeting }} {{ name }}!'); 49 | 50 | template.updateContext({'greeting': 'Hello', 'name': 'World'}); 51 | expect(await template.renderAsync(), equals('Hello World!')); 52 | 53 | template.updateContext({'greeting': 'Goodbye'}); 54 | expect(await template.renderAsync(), equals('Goodbye World!')); 55 | }); 56 | }); 57 | } 58 | -------------------------------------------------------------------------------- /test/tags/raw_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('Raw Tag', () { 19 | group('sync evaluation', () { 20 | test('shows raw text', () async { 21 | await testParser('''{% raw %}{% liquid 22 | assign my_variable = "string" 23 | %}{% endraw %}''', (document) { 24 | evaluator.evaluateNodes(document.children); 25 | expect(evaluator.buffer.toString(), '''{% liquid 26 | assign my_variable = "string" 27 | %}'''); 28 | }); 29 | }); 30 | 31 | test('preserves liquid tags', () async { 32 | await testParser('''{% raw %} 33 | {% if user %} 34 | Hello {{ user.name }}! 35 | {% endif %}{% endraw %}''', (document) { 36 | evaluator.evaluateNodes(document.children); 37 | expect(evaluator.buffer.toString().trim(), '''{% if user %} 38 | Hello {{ user.name }}! 39 | {% endif %}'''); 40 | }); 41 | }); 42 | }); 43 | 44 | group('async evaluation', () { 45 | test('shows raw text', () async { 46 | await testParser('''{% raw %}{% liquid 47 | assign my_variable = "string" 48 | %}{% endraw %}''', (document) async { 49 | await evaluator.evaluateNodesAsync(document.children); 50 | expect(evaluator.buffer.toString(), '''{% liquid 51 | assign my_variable = "string" 52 | %}'''); 53 | }); 54 | }); 55 | 56 | test('preserves liquid tags', () async { 57 | await testParser('''{% raw %} 58 | {% if user %} 59 | Hello {{ user.name }}! 60 | {% endif %}{% endraw %}''', (document) async { 61 | await evaluator.evaluateNodesAsync(document.children); 62 | expect(evaluator.buffer.toString().trim(), '''{% if user %} 63 | Hello {{ user.name }}! 64 | {% endif %}'''); 65 | }); 66 | }); 67 | }); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /lib/src/analyzer/template_analysis.dart: -------------------------------------------------------------------------------- 1 | import 'template_structure.dart'; 2 | 3 | /// Represents the results of analyzing a Liquid template and its inheritance chain. 4 | /// 5 | /// TemplateAnalysis collects information about: 6 | /// * The structures of all templates in the inheritance chain 7 | /// * Any warnings or errors encountered during analysis 8 | /// * The relationships between templates 9 | /// 10 | /// This class is typically used as the return value from template analysis 11 | /// operations and provides access to the complete analysis results. 12 | class TemplateAnalysis { 13 | /// Map of template paths to their analyzed structures. 14 | /// 15 | /// The keys are the paths to the templates (relative to the root), 16 | /// and the values are the [TemplateStructure] objects containing 17 | /// the analysis results for each template. 18 | /// 19 | /// This includes both the main template being analyzed and any 20 | /// parent templates it extends or includes. 21 | final Map structures; 22 | 23 | /// List of warnings generated during template analysis. 24 | /// 25 | /// Warnings might include: 26 | /// * Missing template files 27 | /// * Invalid block structures 28 | /// * Inheritance issues 29 | /// * Other non-fatal problems encountered during analysis 30 | final List warnings; 31 | 32 | /// Creates a new template analysis result. 33 | /// 34 | /// Initializes empty maps and lists for collecting analysis results. 35 | /// The analysis will be populated as the template and its inheritance 36 | /// chain are processed. 37 | TemplateAnalysis() 38 | : structures = {}, 39 | warnings = []; 40 | 41 | /// Converts the analysis results to a JSON-compatible map. 42 | /// 43 | /// This is useful for: 44 | /// * Debugging template analysis 45 | /// * Serializing analysis results 46 | /// * Generating reports 47 | /// 48 | /// Returns a map containing: 49 | /// * warnings: List of warning messages 50 | /// * structures: Map of template paths to their structures 51 | Map toJson() { 52 | return { 53 | 'warnings': warnings, 54 | 'structures': structures, 55 | }; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/analyzer/resolver/ast_matcher.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/parser.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | /// A utility class for building matchers to validate AST structures 5 | class ASTMatcher { 6 | /// Matches a Tag node with the given name and optional content/body matchers 7 | static Matcher tag( 8 | String name, { 9 | List? content, 10 | List? body, 11 | }) { 12 | return isA() 13 | .having((t) => t.name, 'name', equals(name)) 14 | .having( 15 | (t) => t.content, 16 | 'content', 17 | content == null ? anything : containsAll(content), 18 | ) 19 | .having( 20 | (t) => t.body, 21 | 'body', 22 | body == null ? anything : containsAll(body), 23 | ); 24 | } 25 | 26 | /// Matches a TextNode with the given text 27 | static Matcher text(String text) { 28 | return isA().having((t) => t.text, 'text', contains(text)); 29 | } 30 | 31 | /// Matches literal content 32 | static Matcher literal(dynamic value) { 33 | return isA().having((l) => l.value, 'value', contains(value)); 34 | } 35 | 36 | /// Matches an identifier with the given name 37 | static Matcher identifier(String name) { 38 | return isA().having((i) => i.name, 'name', contains(name)); 39 | } 40 | 41 | /// Validates that the AST contains nodes matching all the given matchers 42 | static void validateAST(List ast, List matchers) { 43 | for (var matcher in matchers) { 44 | expect( 45 | ast, 46 | contains(matcher), 47 | reason: 'AST should contain node matching: $matcher', 48 | ); 49 | } 50 | } 51 | 52 | /// Helper to find a specific block in the AST by name 53 | static Tag? findBlock(List ast, String blockName) { 54 | for (var node in ast) { 55 | if (node is Tag && node.name == 'block') { 56 | // Check if this block's content contains the target name 57 | for (var content in node.content) { 58 | if (content is Identifier && content.name == blockName || 59 | content is Literal && content.value.toString() == blockName) { 60 | return node; 61 | } 62 | } 63 | } 64 | } 65 | return null; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /test/analyzer/analyzer/layout_analyzer_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 2 | import 'package:liquify/src/util.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../../shared_test_root.dart'; 6 | 7 | void main() { 8 | group('Layout Analyzer', () { 9 | late TestRoot root; 10 | late TemplateAnalyzer analyzer; 11 | 12 | setUp(() { 13 | // Configure logging - enable only analyzer logs 14 | Logger.disableAllContexts(); 15 | // Logger.enableContext('Analyzer'); 16 | root = TestRoot(); 17 | analyzer = TemplateAnalyzer(root); 18 | 19 | // Base template with simple blocks. 20 | root.addFile('base.liquid', ''' 21 | 22 | 23 | 24 | {% block head %} 25 | Base Title 26 | {% endblock %} 27 | 28 | 29 | {% block content %}{% endblock %} 30 | 31 | 32 | '''); 33 | 34 | // Child template that extends the base. 35 | root.addFile('child.liquid', ''' 36 | {% layout 'base.liquid' %} 37 | 38 | {% block head %} 39 | Child Title 40 | {% endblock %} 41 | 42 | {% block content %} 43 |

Child content

44 | {% endblock %} 45 | '''); 46 | }); 47 | 48 | test('analyzes layout inheritance', () async { 49 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 50 | expect(analysis.structures.containsKey('child.liquid'), isTrue); 51 | 52 | final childStructure = analysis.structures['child.liquid']!; 53 | final resolvedBlocks = childStructure.resolvedBlocks; 54 | 55 | // Check for expected block keys. 56 | expect(resolvedBlocks.keys, containsAll(['head', 'content'])); 57 | 58 | // The child overrides the head block from the base. 59 | final headBlock = resolvedBlocks['head']; 60 | expect(headBlock, isNotNull); 61 | expect(headBlock!.isOverride, isTrue); 62 | 63 | // The content block is defined only in the child. 64 | final contentBlock = resolvedBlocks['content']; 65 | expect(contentBlock, isNotNull); 66 | expect(contentBlock!.isOverride, isTrue); 67 | }); 68 | }); 69 | } 70 | -------------------------------------------------------------------------------- /test/analyzer/resolver/multi_level_inheritance_merge_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 2 | import 'package:liquify/src/analyzer/resolver.dart'; 3 | import 'package:liquify/src/util.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import '../../shared_test_root.dart'; 7 | import 'ast_matcher.dart'; 8 | 9 | void main() { 10 | group('Multi-Level Inheritance Merge', () { 11 | late TestRoot root; 12 | late TemplateAnalyzer analyzer; 13 | 14 | setUp(() { 15 | // Disable analyzer logging and enable only resolver logging 16 | Logger.disableAllContexts(); 17 | // Logger.enableContext('Resolver'); 18 | root = TestRoot(); 19 | analyzer = TemplateAnalyzer(root); 20 | 21 | // Grandparent template: complete layout with blocks 'title' and 'content'. 22 | root.addFile('grandparent.liquid', ''' 23 | 24 | 25 | 26 | {% block title %}Grandparent Title{% endblock %} 27 | 28 | 29 | {% block content %}Grandparent Content{% endblock %} 30 | 31 | 32 | '''); 33 | 34 | // Parent template: extends grandparent and overrides the title. 35 | root.addFile('parent.liquid', ''' 36 | {% layout 'grandparent.liquid' %} 37 | {% block title %}Parent Title{% endblock %} 38 | '''); 39 | 40 | // Child template: extends parent and overrides the content. 41 | root.addFile('child.liquid', ''' 42 | {% layout 'parent.liquid' %} 43 | {% block content %}Child Content{% endblock %} 44 | '''); 45 | }); 46 | 47 | test('merged AST combines multi-level inheritance correctly', () async { 48 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 49 | final structure = analysis.structures['child.liquid']!; 50 | final mergedAst = buildCompleteMergedAst(structure); 51 | 52 | ASTMatcher.validateAST(mergedAst, [ 53 | ASTMatcher.text(''), 54 | // In a multi-level scenario, grandparent defines title as "Grandparent Title", 55 | // parent overrides title with "Parent Title", and child overrides content. 56 | ASTMatcher.text('Parent Title'), 57 | ASTMatcher.text('Child Content'), 58 | ASTMatcher.text(''), 59 | ]); 60 | }); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /lib/src/tags/repeat.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/parser.dart'; 2 | 3 | class RepeatTag extends AbstractTag with AsyncTag, CustomTagParser { 4 | RepeatTag(super.content, super.filters); 5 | 6 | @override 7 | dynamic evaluate(Evaluator evaluator, Buffer buffer) { 8 | final count = evaluator.evaluate(content.first); 9 | final times = count is int ? count : int.parse(count.toString()); 10 | 11 | final buffers = List.generate(times, (_) { 12 | Buffer currentBuffer = Buffer(); 13 | final inner = evaluator.createInnerEvaluatorWithBuffer(currentBuffer); 14 | 15 | for (final node in body) { 16 | final value = inner.evaluate(node); 17 | if (value != null) currentBuffer.write(value); 18 | } 19 | return currentBuffer.toString().trim(); 20 | }); 21 | 22 | // Join with single space and apply filters 23 | final value = buffers.join(' '); 24 | buffer.write(applyFilters(value, evaluator)); 25 | } 26 | 27 | @override 28 | Future evaluateAsync(Evaluator evaluator, Buffer buffer) async { 29 | final times = 30 | int.parse((await evaluator.evaluateAsync(content.first)).toString()); 31 | final contentNodes = List.from(body); 32 | 33 | final buffers = await Future.wait(List.generate(times, (_) async { 34 | Buffer currentBuffer = Buffer(); 35 | final inner = evaluator.createInnerEvaluatorWithBuffer(currentBuffer); 36 | 37 | for (final node in contentNodes) { 38 | final value = await inner.evaluateAsync(node); 39 | if (value != null) currentBuffer.write(value); 40 | } 41 | return currentBuffer.toString().trim(); 42 | })); 43 | 44 | final repeatedContent = buffers.join(' '); 45 | final filtered = await applyFiltersAsync(repeatedContent, evaluator); 46 | buffer.write(filtered); 47 | } 48 | 49 | Parser repeatTag() => someTag("repeat"); 50 | 51 | Parser repeatBlock() => seq3( 52 | ref0(repeatTag), 53 | ref0(element).starLazy(endRepeatTag()), 54 | ref0(endRepeatTag), 55 | ).map((values) { 56 | return values.$1.copyWith(body: values.$2.cast()); 57 | }); 58 | 59 | Parser endRepeatTag() => 60 | (tagStart() & string('endrepeat').trim() & tagEnd()).map((values) { 61 | return Tag('endrepeat', []); 62 | }); 63 | 64 | @override 65 | Parser parser() => repeatBlock(); 66 | } 67 | -------------------------------------------------------------------------------- /docs/tags/utility.md: -------------------------------------------------------------------------------- 1 | # Utility Tags 2 | 3 | Utility tags provide essential functionality for content handling, flow control, and template management. 4 | 5 | ## raw 6 | 7 | Outputs content without processing any Liquid syntax within it. 8 | 9 | ### Syntax 10 | ```liquid 11 | {% raw %} 12 | content with {{ liquid }} syntax that won't be processed 13 | {% endraw %} 14 | ``` 15 | 16 | ### Examples 17 | ```liquid 18 | {% raw %} 19 | This {{ variable }} and {% tag %} won't be processed 20 | {% endraw %} 21 | 22 | ``` 23 | 24 | --- 25 | 26 | ## comment 27 | 28 | Adds comments to templates that are not rendered in the output. 29 | 30 | ### Syntax 31 | ```liquid 32 | {% comment %} 33 | This is a comment and won't appear in output 34 | {% endcomment %} 35 | ``` 36 | 37 | ### Examples 38 | ```liquid 39 | {% comment %} 40 | TODO: Add error handling here 41 | {% endcomment %} 42 | 43 | ``` 44 | 45 | --- 46 | 47 | ## break 48 | 49 | Exits the current loop early. 50 | 51 | ### Syntax 52 | ```liquid 53 | {% break %} 54 | ``` 55 | 56 | ### Examples 57 | ```liquid 58 | {% for item in (1..5) %} 59 | {% if item == 3 %}{% break %}{% endif %} 60 | {{ item }} 61 | {% endfor %} 62 | 63 | ``` 64 | 65 | --- 66 | 67 | ## continue 68 | 69 | Skips the rest of the current iteration and moves to the next. 70 | 71 | ### Syntax 72 | ```liquid 73 | {% continue %} 74 | ``` 75 | 76 | ### Examples 77 | ```liquid 78 | {% for item in (1..5) %} 79 | {% if item == 3 %}{% continue %}{% endif %} 80 | {{ item }} 81 | {% endfor %} 82 | 83 | ``` 84 | 85 | ## Usage Patterns 86 | 87 | ### Conditional Content Processing 88 | ```liquid 89 | {% for product in products %} 90 | {% unless product.available %}{% continue %}{% endunless %} 91 | 92 | {% if product.price > 1000 %}{% break %}{% endif %} 93 | 94 |
{{ product.title }}
95 | {% endfor %} 96 | ``` 97 | 98 | ### Template Documentation 99 | ```liquid 100 | {% comment %} 101 | Product listing template 102 | Variables required: products (array) 103 | Optional: featured_products (array) 104 | {% endcomment %} 105 | 106 | {% raw %} 107 | Example usage: {{ product.title | truncate: 20 }} 108 | {% endraw %} 109 | ``` -------------------------------------------------------------------------------- /lib/src/tags/unless.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/exceptions.dart'; 2 | import 'package:liquify/src/tag.dart'; 3 | import 'package:liquify/src/util.dart'; 4 | 5 | class UnlessTag extends AbstractTag with CustomTagParser, AsyncTag { 6 | bool conditionMet = false; 7 | 8 | UnlessTag(super.content, super.filters); 9 | 10 | void _renderBlockSync( 11 | Evaluator evaluator, Buffer buffer, List body) { 12 | for (final subNode in body) { 13 | try { 14 | if (subNode is Tag) { 15 | evaluator.evaluate(subNode); 16 | } else { 17 | buffer.write(evaluator.evaluate(subNode)); 18 | } 19 | } on BreakException { 20 | throw BreakException(); 21 | } on ContinueException { 22 | throw ContinueException(); 23 | } 24 | } 25 | } 26 | 27 | Future _renderBlockAsync( 28 | Evaluator evaluator, Buffer buffer, List body) async { 29 | for (final subNode in body) { 30 | try { 31 | if (subNode is Tag) { 32 | await evaluator.evaluateAsync(subNode); 33 | } else { 34 | buffer.write(await evaluator.evaluateAsync(subNode)); 35 | } 36 | } on BreakException { 37 | throw BreakException(); 38 | } on ContinueException { 39 | throw ContinueException(); 40 | } 41 | } 42 | } 43 | 44 | @override 45 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 46 | conditionMet = isTruthy(evaluator.evaluate(content[0])); 47 | if (!conditionMet) { 48 | _renderBlockSync(evaluator, buffer, body); 49 | } 50 | } 51 | 52 | @override 53 | Future evaluateWithContextAsync( 54 | Evaluator evaluator, Buffer buffer) async { 55 | conditionMet = isTruthy(await evaluator.evaluateAsync(content[0])); 56 | if (!conditionMet) { 57 | await _renderBlockAsync(evaluator, buffer, body); 58 | } 59 | } 60 | 61 | @override 62 | Parser parser() { 63 | return (ref0(unlessTag).trim() & 64 | any().plusLazy(endUnlessTag()) & 65 | endUnlessTag()) 66 | .map((values) { 67 | return (values[0] as Tag) 68 | .copyWith(body: parseInput((values[1] as List).join(''))); 69 | }); 70 | } 71 | } 72 | 73 | Parser unlessTag() => someTag("unless"); 74 | 75 | Parser endUnlessTag() => 76 | (tagStart() & string('endunless').trim() & tagEnd()).map((values) { 77 | return Tag('endunless', []); 78 | }); 79 | -------------------------------------------------------------------------------- /test/analyzer/analyzer/nested_blocks_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 2 | import 'package:liquify/src/util.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../../shared_test_root.dart'; 6 | 7 | void main() { 8 | group('Nested Blocks Analysis', () { 9 | late TestRoot root; 10 | late TemplateAnalyzer analyzer; 11 | 12 | setUp(() { 13 | // Configure logging - enable only analyzer logs 14 | Logger.disableAllContexts(); 15 | // Logger.enableContext('Analyzer'); 16 | root = TestRoot(); 17 | analyzer = TemplateAnalyzer(root); 18 | 19 | // Base template with a nested block. 20 | root.addFile('base.liquid', ''' 21 | {% block header %} 22 |
23 | {% block navigation %} 24 |
  • Base Navigation
25 | {% endblock %} 26 |
27 | {% endblock %} 28 | '''); 29 | 30 | // Child template extends the base and overrides the nested "navigation" block. 31 | root.addFile('child.liquid', ''' 32 | {% layout 'base.liquid' %} 33 | 34 | {% block navigation %} 35 |
  • Child Navigation
36 | {% endblock %} 37 | '''); 38 | }); 39 | 40 | test('analyzes nested blocks', () async { 41 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 42 | expect(analysis.structures.containsKey('child.liquid'), isTrue); 43 | 44 | final childStructure = analysis.structures['child.liquid']!; 45 | final resolvedBlocks = childStructure.resolvedBlocks; 46 | 47 | // Expect the childStructure to contain the "header" block and a nested block "header.navigation". 48 | expect(resolvedBlocks.keys, containsAll(['header', 'header.navigation'])); 49 | 50 | // The header block should originate from the base template. 51 | final headerBlock = resolvedBlocks['header']; 52 | expect(headerBlock, isNotNull); 53 | expect(headerBlock!.source, equals('base.liquid')); 54 | expect(headerBlock.nestedBlocks, isNotEmpty); 55 | expect(headerBlock.nestedBlocks, contains('navigation')); 56 | 57 | // The nested "navigation" block (as "header.navigation") should be overridden by the child. 58 | final navigationBlock = resolvedBlocks['header.navigation']; 59 | expect(navigationBlock, isNotNull); 60 | expect(navigationBlock!.source, equals('child.liquid')); 61 | expect(navigationBlock.isOverride, isTrue); 62 | }); 63 | }); 64 | } 65 | -------------------------------------------------------------------------------- /example/drop.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/liquify.dart'; 2 | 3 | void main() { 4 | // Create an instance of ProductDrop 5 | final product = ProductDrop( 6 | name: 'Smartphone', 7 | price: 599.99, 8 | manufacturer: ManufacturerDrop( 9 | name: 'TechCorp', 10 | country: 'Japan', 11 | ), 12 | ); 13 | 14 | FilterRegistry.register('discounted_price', (input, args, namedArgs) { 15 | if (input is! num || args.isEmpty || args[0] is! num) { 16 | return input; 17 | } 18 | 19 | num price = input; 20 | num discountPercentage = args[0]; 21 | 22 | if (discountPercentage < 0 || discountPercentage > 100) { 23 | return price; 24 | } 25 | 26 | return price - (price * discountPercentage / 100); 27 | }); 28 | 29 | // Define and render templates 30 | final templates = [ 31 | '{{ product.name }} costs \${{ product.price }}', 32 | 'Manufacturer: {{ product.manufacturer.name }}', 33 | 'Country of origin: {{ product.manufacturer.country }}', 34 | 'Discounted price: {{ product.price | discounted_price: 10 }}', 35 | 'Is expensive? {{ product.is_expensive }}', 36 | ]; 37 | 38 | for (final template in templates) { 39 | final result = Template.parse( 40 | template, 41 | data: { 42 | 'product': product, 43 | }, 44 | ); 45 | print(result.render()); 46 | } 47 | } 48 | 49 | class ProductDrop extends Drop { 50 | final String name; 51 | final double price; 52 | final ManufacturerDrop manufacturer; 53 | 54 | ProductDrop({ 55 | required this.name, 56 | required this.price, 57 | required this.manufacturer, 58 | }); 59 | 60 | @override 61 | Map get attrs => { 62 | 'name': name, 63 | 'price': price, 64 | 'manufacturer': manufacturer, 65 | }; 66 | 67 | @override 68 | List get invokable => [ 69 | ...super.invokable, 70 | #is_expensive, 71 | ]; 72 | 73 | @override 74 | invoke(Symbol symbol, [List? args]) { 75 | switch (symbol) { 76 | case #is_expensive: 77 | return price > 500 ? 'Yes' : 'No'; 78 | default: 79 | return liquidMethodMissing(symbol); 80 | } 81 | } 82 | } 83 | 84 | class ManufacturerDrop extends Drop { 85 | final String name; 86 | final String country; 87 | 88 | ManufacturerDrop({ 89 | required this.name, 90 | required this.country, 91 | }); 92 | 93 | @override 94 | Map get attrs => { 95 | 'name': name, 96 | 'country': country, 97 | }; 98 | } 99 | -------------------------------------------------------------------------------- /test/analyzer/resolver/nested_merge_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/liquify.dart'; 2 | import 'package:liquify/src/analyzer/resolver.dart'; 3 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 4 | import 'package:test/test.dart'; 5 | import '../../shared_test_root.dart'; 6 | import 'ast_matcher.dart'; 7 | import 'package:liquify/src/util.dart'; 8 | 9 | void main() { 10 | group('Nested Merge', () { 11 | late TestRoot root; 12 | late TemplateAnalyzer analyzer; 13 | 14 | setUp(() { 15 | // Configure logging - enable only resolver logs 16 | Logger.disableAllContexts(); 17 | // Logger.enableContext('Resolver'); 18 | root = TestRoot(); 19 | analyzer = TemplateAnalyzer(root); 20 | 21 | // Parent template: defines layout with a header block that contains a nested 'navigation' block. 22 | root.addFile('parent.liquid', ''' 23 | 24 | 25 | 26 | {% block title %}Default Title{% endblock %} 27 | 28 | 29 |
30 | {% block header %} 31 |
Default Header
32 | {% block navigation %}Default Navigation{% endblock %} 33 | {% endblock %} 34 |
35 |
36 | {% block content %}Default Content{% endblock %} 37 |
38 | 39 | 40 | '''); 41 | 42 | // Child template: overrides the 'navigation' block. 43 | root.addFile('child.liquid', ''' 44 | {% layout 'parent.liquid' %} 45 | {% block navigation %}Overridden Navigation{% endblock %} 46 | '''); 47 | }); 48 | 49 | test('merged AST injects nested block override', () async { 50 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 51 | final structure = analysis.structures['child.liquid']!; 52 | final mergedAst = buildCompleteMergedAst(structure); 53 | 54 | ASTMatcher.validateAST(mergedAst, [ 55 | ASTMatcher.text(''), 56 | ASTMatcher.text(''), 57 | ASTMatcher.text(''), 58 | ASTMatcher.text(''), 59 | ASTMatcher.text('
'), 60 | ASTMatcher.text('
'), 61 | ASTMatcher.text('Default Title'), 62 | ASTMatcher.text('Default Header'), 63 | ASTMatcher.text('Overridden Navigation'), 64 | ASTMatcher.text('Default Content'), 65 | ASTMatcher.text(''), 66 | ASTMatcher.text(''), 67 | ASTMatcher.text(''), 68 | ]); 69 | }); 70 | }); 71 | } 72 | -------------------------------------------------------------------------------- /example/example.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/liquify.dart'; 2 | import 'package:liquify/parser.dart'; 3 | 4 | void main() { 5 | // Basic Template Rendering 6 | basicTemplateRendering(); 7 | 8 | // Custom Tag 9 | customTagExample(); 10 | 11 | // Custom Filter 12 | customFilterExample(); 13 | } 14 | 15 | void basicTemplateRendering() { 16 | print('\n--- Basic Template Rendering ---'); 17 | 18 | final data = { 19 | 'name': 'Alice', 20 | 'items': ['apple', 'banana', 'cherry'] 21 | }; 22 | 23 | final result = Template.parse( 24 | 'Hello, {{ name | upcase }}! Your items are: {% for item in items %}{{ item }}{% unless forloop.last %}, {% endunless %}{% endfor %}.', 25 | data: data); 26 | 27 | print(result.render()); 28 | // Output: Hello, ALICE! Your items are: apple, banana, cherry. 29 | } 30 | 31 | void customTagExample() { 32 | print('\n--- Custom Tag Example ---'); 33 | 34 | // Register the custom tag 35 | TagRegistry.register( 36 | 'reverse', (content, filters) => ReverseTag(content, filters)); 37 | 38 | // Use the custom tag 39 | final result = Template.parse('{% reverse %}Hello, World!{% endreverse %}'); 40 | print(result.render()); 41 | // Output: !dlroW ,olleH 42 | } 43 | 44 | void customFilterExample() { 45 | print('\n--- Custom Filter Example ---'); 46 | 47 | // Register a custom filter 48 | FilterRegistry.register('multiply', (value, args, _) { 49 | final multiplier = args.isNotEmpty ? args[0] as num : 2; 50 | return (value as num) * multiplier; 51 | }); 52 | 53 | // Use the custom filter 54 | final result = Template.parse('{{ price | multiply: 1.1 | round }}', 55 | data: {'price': 100}); 56 | print(result.render()); 57 | // Output: 110 58 | } 59 | 60 | class ReverseTag extends AbstractTag with CustomTagParser { 61 | ReverseTag(super.content, super.filters); 62 | 63 | @override 64 | dynamic evaluate(Evaluator evaluator, Buffer buffer) { 65 | String result = content 66 | .map((node) => evaluator.evaluate(node).toString()) 67 | .join('') 68 | .split('') 69 | .reversed 70 | .join(''); 71 | buffer.write(result); 72 | } 73 | 74 | @override 75 | Parser parser() { 76 | return (tagStart() & 77 | string('reverse').trim() & 78 | tagEnd() & 79 | any() 80 | .starLazy(tagStart() & string('endreverse').trim() & tagEnd()) 81 | .flatten() & 82 | tagStart() & 83 | string('endreverse').trim() & 84 | tagEnd()) 85 | .map((values) { 86 | return Tag("reverse", [TextNode(values[3])]); 87 | }); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /test/analyzer/analyzer/super_call_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/parser.dart'; 2 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 3 | import 'package:liquify/src/util.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import '../../shared_test_root.dart'; 7 | 8 | void main() { 9 | group('Super Call Analysis', () { 10 | late TestRoot root; 11 | late TemplateAnalyzer analyzer; 12 | 13 | setUp(() { 14 | // Configure logging - enable only analyzer logs 15 | Logger.disableAllContexts(); 16 | // Logger.enableContext('Analyzer'); 17 | root = TestRoot(); 18 | analyzer = TemplateAnalyzer(root); 19 | 20 | // Base template defines a block with some content. 21 | root.addFile('base.liquid', ''' 22 | {% block content %} 23 |

Base Content

24 | {% endblock %} 25 | '''); 26 | 27 | // Child template overrides it and calls super(). 28 | root.addFile('child.liquid', ''' 29 | {% layout 'base.liquid' %} 30 | {% block content %} 31 |
Child Content Before
32 | {{ super() }} 33 |
Child Content After
34 | {% endblock %} 35 | '''); 36 | }); 37 | 38 | test('merges parent block content with super()', () async { 39 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 40 | final childStructure = analysis.structures['child.liquid']!; 41 | final resolvedBlocks = childStructure.resolvedBlocks; 42 | 43 | // For example, we expect the child template's "content" block 44 | // (possibly flattened as just "content" if no nesting is required) 45 | // to merge the child's and parent's content. 46 | final contentBlock = resolvedBlocks['content']; 47 | expect(contentBlock, isNotNull); 48 | expect(contentBlock!.source, equals('child.liquid')); 49 | 50 | // Verify that the block was detected as an override and contains a super() call. 51 | expect(contentBlock.hasSuperCall, isTrue); 52 | expect(contentBlock.isOverride, isTrue); 53 | expect(contentBlock.parent, isNotNull); 54 | expect(contentBlock.parent!.source, equals('base.liquid')); 55 | 56 | // Additionally, check that at least one node in the block's content is a Tag named "super". 57 | bool foundSuper = (contentBlock.content ?? []) 58 | .any((n) => n is Tag && n.name == 'super'); 59 | expect(foundSuper, isTrue, 60 | reason: 'The block content should include a super() call tag.'); 61 | 62 | // This test will eventually verify that the rendered output is: 63 | // "
Child Content Before

Base Content

Child Content After
" 64 | // Once your evaluator supports super(). 65 | }); 66 | }); 67 | } 68 | -------------------------------------------------------------------------------- /docs/tags/layout.md: -------------------------------------------------------------------------------- 1 | # Layout & Rendering Tags 2 | 3 | Layout tags handle template composition, inheritance, and partial rendering for building complex template structures. 4 | 5 | ## layout 6 | 7 | Defines the base layout template that child templates extend. 8 | 9 | ### Syntax 10 | ```liquid 11 | {% layout "layout_name" %} 12 | ``` 13 | 14 | ### Examples 15 | ```liquid 16 | {% layout "base" %} 17 | 18 | 19 |

Page Title

20 |

Page content

21 | ``` 22 | 23 | --- 24 | 25 | ## block 26 | 27 | Defines a replaceable content block within a layout template. 28 | 29 | ### Syntax 30 | ```liquid 31 | {% block block_name %} 32 | default content 33 | {% endblock %} 34 | ``` 35 | 36 | ### Examples 37 | ```liquid 38 | 39 | 40 | 41 | 42 | {% block head %} 43 | Default Title 44 | {% endblock %} 45 | 46 | 47 | {% block content %} 48 |

Default content

49 | {% endblock %} 50 | 51 | 52 | ``` 53 | 54 | --- 55 | 56 | ## super 57 | 58 | Calls the parent block's content when overriding a block. 59 | 60 | ### Syntax 61 | ```liquid 62 | {% block block_name %} 63 | {% super %} 64 | additional content 65 | {% endblock %} 66 | ``` 67 | 68 | ### Examples 69 | ```liquid 70 | {% block head %} 71 | {% super %} 72 | 73 | {% endblock %} 74 | ``` 75 | 76 | --- 77 | 78 | ## render 79 | 80 | Includes and renders another template file. 81 | 82 | ### Syntax 83 | ```liquid 84 | {% render "template_name" %} 85 | {% render "template_name", variable: value %} 86 | ``` 87 | 88 | ### Examples 89 | ```liquid 90 | {% render "product_card", product: product %} 91 | {% render "header" %} 92 | ``` 93 | 94 | ## Layout Inheritance Patterns 95 | 96 | ### Base Layout 97 | ```liquid 98 | 99 | 100 | 101 | 102 | {% block title %}Default Title{% endblock %} 103 | {% block head %}{% endblock %} 104 | 105 | 106 |
{% render "header" %}
107 |
{% block content %}{% endblock %}
108 |
{% render "footer" %}
109 | 110 | 111 | ``` 112 | 113 | ### Child Template 114 | ```liquid 115 | {% layout "base" %} 116 | 117 | {% block title %}Product Page{% endblock %} 118 | 119 | {% block head %} 120 | {% super %} 121 | 122 | {% endblock %} 123 | 124 | {% block content %} 125 |

{{ product.title }}

126 | {% render "product_details", product: product %} 127 | {% endblock %} 128 | ``` -------------------------------------------------------------------------------- /test/tags/echo_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('EchoTag', () { 19 | group('sync evaluation', () { 20 | test('echoes variable with filter', () async { 21 | await testParser(''' 22 | {% assign username = "Bob" %} 23 | {% echo username | append: ", welcome to LiquidJS!" %} 24 | ''', (document) { 25 | evaluator.evaluateNodes(document.children); 26 | expect( 27 | evaluator.buffer.toString().trim(), 'Bob, welcome to LiquidJS!'); 28 | }); 29 | }); 30 | 31 | test('echoes variable with multiple filters', () async { 32 | await testParser(''' 33 | {% assign name = "bob" %} 34 | {% echo name | capitalize | append: ", hello!" %} 35 | ''', (document) { 36 | evaluator.evaluateNodes(document.children); 37 | expect(evaluator.buffer.toString().trim(), 'Bob, hello!'); 38 | }); 39 | }); 40 | 41 | test('echoes string with filters', () async { 42 | await testParser(''' 43 | {% echo "hello world" | split: " " | first | capitalize %} 44 | ''', (document) { 45 | evaluator.evaluateNodes(document.children); 46 | expect(evaluator.buffer.toString().trim(), 'Hello'); 47 | }); 48 | }); 49 | }); 50 | 51 | group('async evaluation', () { 52 | test('echoes variable with filter', () async { 53 | await testParser(''' 54 | {% assign username = "Bob" %} 55 | {% echo username | append: ", welcome to LiquidJS!" %} 56 | ''', (document) async { 57 | await evaluator.evaluateNodesAsync(document.children); 58 | expect( 59 | evaluator.buffer.toString().trim(), 'Bob, welcome to LiquidJS!'); 60 | }); 61 | }); 62 | 63 | test('echoes variable with multiple filters', () async { 64 | await testParser(''' 65 | {% assign name = "bob" %} 66 | {% echo name | capitalize | append: ", hello!" %} 67 | ''', (document) async { 68 | await evaluator.evaluateNodesAsync(document.children); 69 | expect(evaluator.buffer.toString().trim(), 'Bob, hello!'); 70 | }); 71 | }); 72 | 73 | test('echoes string with filters', () async { 74 | await testParser(''' 75 | {% echo "hello world" | split: " " | first | capitalize %} 76 | ''', (document) async { 77 | await evaluator.evaluateNodesAsync(document.children); 78 | expect(evaluator.buffer.toString().trim(), 'Hello'); 79 | }); 80 | }); 81 | }); 82 | }); 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/tags/case.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/ast.dart'; 2 | import 'package:liquify/src/buffer.dart'; 3 | import 'package:liquify/src/evaluator.dart'; 4 | import 'package:liquify/src/tags/tag.dart'; 5 | 6 | class CaseTag extends AbstractTag with AsyncTag { 7 | late dynamic caseValue; 8 | 9 | CaseTag(super.content, super.filters); 10 | 11 | @override 12 | void preprocess(Evaluator evaluator) { 13 | if (content.isEmpty) { 14 | throw Exception('CaseTag requires a value to switch on.'); 15 | } 16 | } 17 | 18 | void _evaluateBody( 19 | List nodeBody, Evaluator evaluator, Buffer buffer) { 20 | for (final subNode in nodeBody) { 21 | if (subNode is Tag) { 22 | evaluator.evaluate(subNode); 23 | } else { 24 | buffer.write(evaluator.evaluate(subNode)); 25 | } 26 | } 27 | } 28 | 29 | Future _evaluateBodyAsync( 30 | List nodeBody, Evaluator evaluator, Buffer buffer) async { 31 | for (final subNode in nodeBody) { 32 | if (subNode is Tag) { 33 | await evaluator.evaluateAsync(subNode); 34 | } else { 35 | buffer.write(await evaluator.evaluateAsync(subNode)); 36 | } 37 | } 38 | } 39 | 40 | @override 41 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 42 | caseValue = evaluator.evaluate(content[0]); 43 | Tag? elseTag; 44 | bool matchFound = false; 45 | 46 | for (final node in body) { 47 | if (node is Tag) { 48 | if (node.name == 'when' && !matchFound) { 49 | final whenValues = 50 | node.content.map((e) => evaluator.evaluate(e)).toList(); 51 | if (whenValues.contains(caseValue)) { 52 | _evaluateBody(node.body, evaluator, buffer); 53 | matchFound = true; 54 | } 55 | } else if (node.name == 'else') { 56 | elseTag = node; 57 | } 58 | } 59 | } 60 | 61 | if (!matchFound && elseTag != null) { 62 | _evaluateBody(elseTag.body, evaluator, buffer); 63 | } 64 | } 65 | 66 | @override 67 | Future evaluateWithContextAsync( 68 | Evaluator evaluator, Buffer buffer) async { 69 | caseValue = await evaluator.evaluateAsync(content[0]); 70 | Tag? elseTag; 71 | bool matchFound = false; 72 | 73 | for (final node in body) { 74 | if (node is Tag) { 75 | if (node.name == 'when' && !matchFound) { 76 | final whenValues = await Future.wait( 77 | node.content.map((e) => evaluator.evaluateAsync(e))); 78 | if (whenValues.contains(caseValue)) { 79 | await _evaluateBodyAsync(node.body, evaluator, buffer); 80 | matchFound = true; 81 | } 82 | } else if (node.name == 'else') { 83 | elseTag = node; 84 | } 85 | } 86 | } 87 | 88 | if (!matchFound && elseTag != null) { 89 | await _evaluateBodyAsync(elseTag.body, evaluator, buffer); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /example/custom_tag.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/liquify.dart'; 2 | import 'package:liquify/parser.dart'; 3 | 4 | void main() { 5 | print('Custom Box Tag Example\n'); 6 | 7 | // Register the custom tag 8 | TagRegistry.register('box', (content, filters) => BoxTag(content, filters)); 9 | FilterRegistry.register('sum', (value, args, namedArgs) { 10 | if (value is! List) { 11 | return value; 12 | } 13 | return (value as List).reduce((int a, int b) => a + b); 14 | }); 15 | 16 | // Define the template 17 | final template = ''' 18 | Default box: 19 | {% box %} 20 | Hello, World! 21 | This is a custom box tag. 22 | {% endbox %} 23 | 24 | Custom character box: 25 | {% box * %} 26 | Using a custom box character. 27 | Multiple lines are supported. 28 | {% endbox %} 29 | 30 | Box with calculations: 31 | {% box %} 32 | Total: {{ items | size }} 33 | Sum: {% for item in items %} {{ item }} {% unless forloop.last %} + {% endunless %}{% endfor %} = {{ items | sum }} 34 | {% endbox %} 35 | '''; 36 | 37 | // Create a context with some variables 38 | final context = { 39 | 'name': 'Alice', 40 | 'age': 30, 41 | 'items': [1, 2, 3, 4, 5], 42 | }; 43 | 44 | // Parse and render the template 45 | final result = Template.parse(template, data: context); 46 | 47 | // Print the result 48 | print(result.render()); 49 | } 50 | 51 | class BoxTag extends AbstractTag with CustomTagParser { 52 | BoxTag(super.content, super.filters); 53 | 54 | @override 55 | dynamic evaluate(Evaluator evaluator, Buffer buffer) { 56 | String content = evaluator.evaluate(body[0]).toString().trim(); 57 | 58 | content = Template.parse( 59 | content, 60 | data: evaluator.context.all(), 61 | ).render(); 62 | 63 | String boxChar = this.content.isNotEmpty 64 | ? evaluator.evaluate(this.content[0]).toString() 65 | : '+'; 66 | 67 | List lines = content.split('\n'); 68 | int maxLength = 69 | lines.map((line) => line.length).reduce((a, b) => a > b ? a : b); 70 | 71 | String topBottom = boxChar * (maxLength); 72 | buffer.writeln(topBottom); 73 | 74 | for (String line in lines) { 75 | buffer.writeln('$boxChar ${line.padRight(maxLength)} $boxChar'); 76 | } 77 | 78 | buffer.writeln(topBottom); 79 | } 80 | 81 | @override 82 | Parser parser() { 83 | return (tagStart() & 84 | string('box').trim() & 85 | any().starLazy(tagEnd()).flatten().optional() & 86 | tagEnd() & 87 | any() 88 | .starLazy(tagStart() & string('endbox').trim() & tagEnd()) 89 | .flatten() & 90 | tagStart() & 91 | string('endbox').trim() & 92 | tagEnd()) 93 | .map((values) { 94 | var boxChar = values[2] != null ? TextNode(values[2]) : null; 95 | return Tag("box", boxChar != null ? [boxChar] : [], 96 | body: [TextNode(values[4])]); 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /docs/examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains comprehensive examples demonstrating various features and capabilities of the Liquid Grammar library. 4 | 5 | ## Quick Start Examples 6 | 7 | ### [Basic Usage](basic-usage.md) 8 | - Template rendering fundamentals 9 | - Variable interpolation 10 | - Filters and data transformation 11 | - Custom filters and tags 12 | 13 | ### [Template Layouts](template-layouts.md) 14 | - Layout inheritance patterns 15 | - Block definitions and overrides 16 | - Multi-level template hierarchies 17 | - Dynamic layout selection 18 | 19 | ### [File System Integration](file-system.md) 20 | - Template loading from files 21 | - Template inclusion and rendering 22 | - File system abstraction 23 | - Error handling for missing files 24 | 25 | ## Advanced Examples 26 | 27 | ### [Custom Tags](custom-tags.md) 28 | - Creating custom tag implementations 29 | - Parser integration patterns 30 | - Complex tag logic with multiple blocks 31 | - Tag registration and usage 32 | 33 | ### [Drop Objects](drop-objects.md) 34 | - Custom object model integration 35 | - Property access patterns 36 | - Method invocation from templates 37 | - Nested object hierarchies 38 | 39 | ## Example Categories 40 | 41 | | Category | Description | Examples | 42 | |----------|-------------|----------| 43 | | **Basic** | Fundamental template operations | Variable output, loops, conditionals | 44 | | **Filters** | Data transformation examples | Custom filters, filter chaining | 45 | | **Tags** | Control flow and custom logic | Custom tags, block tags | 46 | | **Layouts** | Template inheritance | Layout systems, block overrides | 47 | | **Objects** | Custom data models | Drop classes, property access | 48 | | **Files** | File system integration | Template loading, includes | 49 | 50 | ## Running Examples 51 | 52 | All examples are self-contained Dart programs that can be run directly: 53 | 54 | ```bash 55 | # Run basic example 56 | dart run example/example.dart 57 | 58 | # Run layout example 59 | dart run example/layout.dart 60 | 61 | # Run custom tag example 62 | dart run example/custom_tag.dart 63 | 64 | # Run drop objects example 65 | dart run example/drop.dart 66 | 67 | # Run file system example 68 | dart run example/fs.dart 69 | ``` 70 | 71 | ## Example Output 72 | 73 | Each example produces formatted output demonstrating the specific features: 74 | 75 | - **Template rendering results** - Showing the final generated content 76 | - **Feature demonstrations** - Highlighting specific functionality 77 | - **Error handling** - Showing graceful failure modes 78 | - **Performance patterns** - Efficient usage examples 79 | 80 | ## Related Documentation 81 | 82 | - [Tags Documentation](../tags/) - Complete tag reference 83 | - [Filters Documentation](../filters/) - Complete filter reference 84 | - [API Reference](../api/) - Core library documentation 85 | 86 | ## Contributing Examples 87 | 88 | When adding new examples: 89 | 90 | 1. Create self-contained, runnable examples 91 | 2. Include comprehensive comments 92 | 3. Demonstrate both basic and advanced usage 93 | 4. Show error handling patterns 94 | 5. Provide expected output in comments -------------------------------------------------------------------------------- /test/tags/dot_notation.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:liquify/src/context.dart'; 4 | import 'package:liquify/src/ast.dart'; 5 | import 'package:liquify/src/filter_registry.dart'; 6 | 7 | void main() { 8 | group('Dot notation filters', () { 9 | setUp(() { 10 | // Register built-in dot notation filters 11 | FilterRegistry.register('size', (value, args, namedArgs) => value.length, 12 | dotNotation: true); 13 | FilterRegistry.register('first', (value, args, namedArgs) => value.first, 14 | dotNotation: true); 15 | FilterRegistry.register('last', (value, args, namedArgs) => value.last, 16 | dotNotation: true); 17 | }); 18 | 19 | test('should apply size filter using dot notation', () { 20 | final context = Environment({ 21 | 'site': { 22 | 'pages': [1, 2, 3, 4, 5] 23 | } 24 | }); 25 | final evaluator = Evaluator(context); 26 | final node = MemberAccess( 27 | Identifier('site'), 28 | [Identifier('pages'), Identifier('size')], 29 | ); 30 | 31 | final result = evaluator.visitMemberAccess(node); 32 | expect(result, 5); 33 | }); 34 | 35 | test('should apply first filter using dot notation', () { 36 | final context = Environment({ 37 | 'collection': { 38 | 'products': [ 39 | {'title': 'Product 1'}, 40 | {'title': 'Product 2'} 41 | ] 42 | } 43 | }); 44 | final evaluator = Evaluator(context); 45 | final node = MemberAccess( 46 | Identifier('collection'), 47 | [Identifier('products'), Identifier('first')], 48 | ); 49 | 50 | final result = evaluator.visitMemberAccess(node); 51 | expect(result, {'title': 'Product 1'}); 52 | }); 53 | 54 | test('should apply last filter using dot notation', () { 55 | final context = Environment({ 56 | 'collection': { 57 | 'products': [ 58 | {'title': 'Product 1'}, 59 | {'title': 'Product 2'} 60 | ] 61 | } 62 | }); 63 | final evaluator = Evaluator(context); 64 | final node = MemberAccess( 65 | Identifier('collection'), 66 | [Identifier('products'), Identifier('last')], 67 | ); 68 | 69 | final result = evaluator.visitMemberAccess(node); 70 | expect(result, {'title': 'Product 2'}); 71 | }); 72 | 73 | test( 74 | 'should allow library users to register their own dot notation filters', 75 | () { 76 | // Register a custom dot notation filter 77 | FilterRegistry.register( 78 | 'custom', (value, args, namedArgs) => 'custom filter applied', 79 | dotNotation: true); 80 | 81 | final context = Environment({'data': 'test'}); 82 | final evaluator = Evaluator(context); 83 | final node = MemberAccess( 84 | Identifier('data'), 85 | [Identifier('custom')], 86 | ); 87 | 88 | final result = evaluator.visitMemberAccess(node); 89 | expect(result, 'custom filter applied'); 90 | }); 91 | }); 92 | } 93 | -------------------------------------------------------------------------------- /test/analyzer/analyzer/multilevel_layout_test.dart: -------------------------------------------------------------------------------- 1 | // File: test/analyzer/multilevel_layout_test.dart 2 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 3 | import 'package:liquify/src/util.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | import '../../shared_test_root.dart'; 7 | 8 | void main() { 9 | group('Multi-Level Inheritance', () { 10 | late TestRoot root; 11 | late TemplateAnalyzer analyzer; 12 | 13 | setUp(() { 14 | // Configure logging - enable only analyzer logs 15 | Logger.disableAllContexts(); 16 | // Logger.enableContext('Analyzer'); 17 | root = TestRoot(); 18 | analyzer = TemplateAnalyzer(root); 19 | 20 | // Grandparent template with base blocks. 21 | root.addFile('grandparent.liquid', ''' 22 | {% block header %} 23 |

Grandparent Header

24 | {% endblock %} 25 | {% block content %} 26 |

Grandparent Content

27 | {% endblock %} 28 | '''); 29 | 30 | // Parent template that extends grandparent. 31 | root.addFile('parent.liquid', ''' 32 | {% layout 'grandparent.liquid' %} 33 | {% block header %} 34 |

Parent Header

35 | {% endblock %} 36 | {% block content %} 37 |

Parent Content

38 | {% endblock %} 39 | '''); 40 | 41 | // Child template that extends parent. 42 | root.addFile('child.liquid', ''' 43 | {% layout 'parent.liquid' %} 44 | {% block header %} 45 |

Child Header

46 | {% endblock %} 47 | {% block footer %} 48 |
Child Footer
49 | {% endblock %} 50 | '''); 51 | }); 52 | 53 | test('analyzes multi-level inheritance', () async { 54 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 55 | expect(analysis.structures.containsKey('child.liquid'), isTrue); 56 | 57 | final childStructure = analysis.structures['child.liquid']!; 58 | final resolvedBlocks = childStructure.resolvedBlocks; 59 | 60 | // Child header should override parent's (and inherit parent's parent). 61 | expect(resolvedBlocks['header'], isNotNull); 62 | expect(resolvedBlocks['header']!.source, equals('child.liquid')); 63 | expect(resolvedBlocks['header']!.isOverride, isTrue); 64 | 65 | // Parent content should be inherited because the child didn't override it. 66 | expect(resolvedBlocks['content'], isNotNull); 67 | // Depending on design, if the parent's block remains and is not overridden, 68 | // its source may still be 'parent.liquid'. In our current design, however, 69 | // a redefinition in the parent is marked as override in the child structure. 70 | // Adjust the expectation based on your intended behavior. 71 | expect(resolvedBlocks['content']!.source, equals('parent.liquid')); 72 | expect(resolvedBlocks['content']!.isOverride, isTrue); 73 | 74 | // The footer is defined only in the child. 75 | expect(resolvedBlocks['footer'], isNotNull); 76 | expect(resolvedBlocks['footer']!.source, equals('child.liquid')); 77 | expect(resolvedBlocks['footer']!.isOverride, isTrue); 78 | }); 79 | }); 80 | } 81 | -------------------------------------------------------------------------------- /test/analyzer/resolver/deeply_nested_super_merge_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/analyzer/resolver.dart'; 2 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 3 | import 'package:test/test.dart'; 4 | import '../../shared_test_root.dart'; 5 | import 'ast_matcher.dart'; 6 | import 'package:liquify/src/util.dart'; 7 | 8 | void main() { 9 | group('Deeply Nested Super Merge', () { 10 | late TestRoot root; 11 | late TemplateAnalyzer analyzer; 12 | 13 | setUp(() { 14 | // Configure logging - enable only resolver logs 15 | Logger.disableAllContexts(); 16 | // Logger.enableContext('Resolver'); 17 | root = TestRoot(); 18 | analyzer = TemplateAnalyzer(root); 19 | 20 | // Grandparent template: defines header with nested navigation. 21 | root.addFile('grandparent.liquid', ''' 22 | 23 | 24 | 25 | {% block title %}Grandparent Title{% endblock %} 26 | 27 | 28 |
29 | {% block header %} 30 |
Grandparent Header
31 | {% block navigation %} 32 | 33 | {% endblock %} 34 | {% endblock %} 35 |
36 |
37 | {% block content %}Grandparent Content{% endblock %} 38 |
39 |
40 | {% block footer %}Grandparent Footer{% endblock %} 41 |
42 | 43 | 44 | '''); 45 | 46 | // Parent template: extends grandparent and overrides navigation. 47 | root.addFile('parent.liquid', ''' 48 | {% layout 'grandparent.liquid' %} 49 | {% block navigation %} 50 | 51 | {% endblock %} 52 | '''); 53 | 54 | // Child template: extends parent and overrides navigation, calling super(). 55 | root.addFile('child.liquid', ''' 56 | {% layout 'parent.liquid' %} 57 | {% block navigation %} 58 | 65 | {% endblock %} 66 | '''); 67 | }); 68 | 69 | test('merged AST reflects deep nesting with super call override', () async { 70 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 71 | final structure = analysis.structures['child.liquid']!; 72 | final mergedAst = buildCompleteMergedAst(structure); 73 | 74 | ASTMatcher.validateAST(mergedAst, [ 75 | ASTMatcher.text(''), 76 | ASTMatcher.text('Grandparent Title'), 77 | ASTMatcher.text('Grandparent Header'), 78 | ASTMatcher.text('Child Nav Before'), 79 | // In our resolution of super(), we expect the parent's override (from parent.liquid) 80 | // to be injected. Parent's navigation content is supposed to be "". 81 | // So we expect "Parent Nav" to appear. 82 | ASTMatcher.text('Parent Nav'), 83 | ASTMatcher.text('Child Nav After'), 84 | ASTMatcher.text('Grandparent Content'), 85 | ASTMatcher.text(''), 86 | ]); 87 | }); 88 | }); 89 | } 90 | -------------------------------------------------------------------------------- /test/tags/cycle_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('CycleTag', () { 19 | group('sync evaluation', () { 20 | test('basic cycling through values', () async { 21 | await testParser( 22 | '{% cycle "one", "two", "three" %}' 23 | '{% cycle "one", "two", "three" %}' 24 | '{% cycle "one", "two", "three" %}' 25 | '{% cycle "one", "two", "three" %}', (document) { 26 | evaluator.evaluateNodes(document.children); 27 | expect(evaluator.buffer.toString(), 'onetwothreeone'); 28 | }); 29 | }); 30 | 31 | test('cycling with named groups', () async { 32 | await testParser( 33 | '{% cycle "group1": "one", "two", "three" %}' 34 | '{% cycle "group2": "a", "b", "c" %}' 35 | '{% cycle "group1": "one", "two", "three" %}' 36 | '{% cycle "group2": "a", "b", "c" %}', (document) { 37 | evaluator.evaluateNodes(document.children); 38 | expect(evaluator.buffer.toString(), 'oneatwob'); 39 | }); 40 | }); 41 | 42 | test('cycling with variables', () async { 43 | await testParser( 44 | '{% assign var1 = "first" %}' 45 | '{% assign var2 = "second" %}' 46 | '{% cycle var1, var2 %}' 47 | '{% cycle var1, var2 %}' 48 | '{% cycle var1, var2 %}', (document) { 49 | evaluator.evaluateNodes(document.children); 50 | expect(evaluator.buffer.toString(), 'firstsecondfirst'); 51 | }); 52 | }); 53 | }); 54 | 55 | group('async evaluation', () { 56 | test('basic cycling through values', () async { 57 | await testParser( 58 | '{% cycle "one", "two", "three" %}' 59 | '{% cycle "one", "two", "three" %}' 60 | '{% cycle "one", "two", "three" %}' 61 | '{% cycle "one", "two", "three" %}', (document) async { 62 | await evaluator.evaluateNodesAsync(document.children); 63 | expect(evaluator.buffer.toString(), 'onetwothreeone'); 64 | }); 65 | }); 66 | 67 | test('cycling with named groups', () async { 68 | await testParser( 69 | '{% cycle "group1": "one", "two", "three" %}' 70 | '{% cycle "group2": "a", "b", "c" %}' 71 | '{% cycle "group1": "one", "two", "three" %}' 72 | '{% cycle "group2": "a", "b", "c" %}', (document) async { 73 | await evaluator.evaluateNodesAsync(document.children); 74 | expect(evaluator.buffer.toString(), 'oneatwob'); 75 | }); 76 | }); 77 | 78 | test('cycling with variables', () async { 79 | await testParser( 80 | '{% assign var1 = "first" %}' 81 | '{% assign var2 = "second" %}' 82 | '{% cycle var1, var2 %}' 83 | '{% cycle var1, var2 %}' 84 | '{% cycle var1, var2 %}', (document) async { 85 | await evaluator.evaluateNodesAsync(document.children); 86 | expect(evaluator.buffer.toString(), 'firstsecondfirst'); 87 | }); 88 | }); 89 | }); 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # Liquid Grammar Documentation 2 | 3 | A comprehensive Dart implementation of the Liquid template language with support for filters, tags, and advanced templating features. 4 | 5 | ## Table of Contents 6 | 7 | ### 📖 [Tags Documentation](tags/) 8 | - **[Control Flow](tags/control-flow.md)** - `if`, `unless`, `case`, conditional logic 9 | - **[Iteration](tags/iteration.md)** - `for`, `tablerow`, `cycle`, `repeat` 10 | - **[Variables](tags/variables.md)** - `assign`, `capture`, `increment`, `decrement` 11 | - **[Layout & Rendering](tags/layout.md)** - `layout`, `block`, `super`, `render` 12 | - **[Output](tags/output.md)** - `echo`, `liquid` 13 | - **[Utility](tags/utility.md)** - `raw`, `comment`, `break`, `continue` 14 | 15 | ### 🔧 [Filters Documentation](filters/) 16 | - **[Array Filters](filters/array.md)** - Array manipulation and processing 17 | - **[String Filters](filters/string.md)** - String transformation and formatting 18 | - **[Math Filters](filters/math.md)** - Mathematical operations 19 | - **[Date Filters](filters/date.md)** - Date formatting and manipulation 20 | - **[HTML Filters](filters/html.md)** - HTML encoding and processing 21 | - **[URL Filters](filters/url.md)** - URL encoding and manipulation 22 | - **[Misc Filters](filters/misc.md)** - Utility filters (json, parse_json, etc.) 23 | 24 | ### 📚 [Examples & Guides](examples/) 25 | - **[Basic Usage](examples/basic-usage.md)** - Template fundamentals and getting started 26 | - **[Template Layouts](examples/template-layouts.md)** - Layout inheritance and composition 27 | - **[Custom Tags](examples/custom-tags.md)** - Creating advanced custom tags 28 | - **[Drop Objects](examples/drop-objects.md)** - Custom object model integration 29 | - **[File System Integration](examples/file-system.md)** - Template loading and organization 30 | 31 | ### 🔒 [Advanced Features](/) 32 | - **[Environment-Scoped Registry](environment-scoped-registry.md)** - Security, isolation, and multi-tenancy 33 | 34 | ## Quick Start 35 | 36 | ### Basic Variable Output 37 | ```liquid 38 | {{ name }} 39 | {{ user.email }} 40 | {{ products[0].title }} 41 | ``` 42 | 43 | ### Basic Control Flow 44 | ```liquid 45 | {% if user.logged_in %} 46 | Welcome back, {{ user.name }}! 47 | {% else %} 48 | Please log in. 49 | {% endif %} 50 | ``` 51 | 52 | ### Basic Iteration 53 | ```liquid 54 | {% for product in products %} 55 |

{{ product.title }}

56 |

{{ product.description }}

57 | {% endfor %} 58 | ``` 59 | 60 | ### Filters 61 | ```liquid 62 | {{ "hello world" | capitalize }} 63 | {{ products | size }} 64 | {{ product.created_at | date: "%B %d, %Y" }} 65 | ``` 66 | 67 | ## Features 68 | 69 | - ✅ **Complete Liquid compatibility** - Full support for standard Liquid syntax 70 | - ✅ **Async support** - Both synchronous and asynchronous evaluation 71 | - ✅ **Comprehensive filters** - 70+ built-in filters across 7 categories 72 | - ✅ **Rich tag library** - 20+ tags for control flow, iteration, and layout 73 | - ✅ **Environment-scoped registry** - Security isolation and multi-tenancy support 74 | - ✅ **Strict mode** - Security sandboxing for untrusted templates 75 | - ✅ **Error handling** - Detailed error messages and exception handling 76 | - ✅ **Extensible** - Easy to add custom filters and tags 77 | - ✅ **Well-tested** - Extensive test coverage with real-world examples 78 | 79 | ## Contributing 80 | 81 | This documentation is automatically generated from source code and tests. To update: 82 | 83 | 1. Modify the source code or tests 84 | 2. Run the documentation generation script 85 | 3. Review and commit the changes 86 | 87 | For more information, see the [Contributing Guide](../CONTRIBUTING.md). -------------------------------------------------------------------------------- /lib/src/filter_registry.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/filters/module.dart'; 2 | import 'package:liquify/src/filters/filters.dart'; 3 | 4 | /// Represents a function that can be used as a filter in the Liquid template engine. 5 | /// 6 | /// [value] The input value to be filtered. 7 | /// [arguments] A list of positional arguments passed to the filter. 8 | /// [namedArguments] A map of named arguments passed to the filter. 9 | typedef FilterFunction = dynamic Function(dynamic value, 10 | List arguments, Map namedArguments); 11 | 12 | /// A registry for storing and retrieving filter functions. 13 | class FilterRegistry { 14 | /// A map of filter names to their corresponding filter functions. 15 | static final Map _filters = {}; 16 | static final Map modules = { 17 | 'array': ArrayModule(), 18 | 'date': DateModule(), 19 | 'html': HtmlModule(), 20 | 'math': MathModule(), 21 | 'misc': MiscModule(), 22 | 'string': StringModule(), 23 | 'url': UrlModule(), 24 | }; 25 | 26 | /// Registers a new filter function with the given name. 27 | /// 28 | /// [name] The name of the filter to be registered. 29 | /// [function] The filter function to be associated with the given name. 30 | /// [dotNotation] If true, the filter can be used with dot notation. 31 | static void register(String name, FilterFunction function, 32 | {bool dotNotation = false}) { 33 | _filters[name] = function; 34 | if (dotNotation) { 35 | _dotNotationFilters.add(name); 36 | } 37 | } 38 | 39 | /// List of filters that can be used with dot notation. 40 | static final Set _dotNotationFilters = {}; 41 | 42 | /// Checks if a filter can be used with dot notation. 43 | /// 44 | /// [name] The name of the filter to check. 45 | /// 46 | /// Returns true if the filter can be used with dot notation, false otherwise. 47 | static bool isDotNotationFilter(String name) { 48 | return _dotNotationFilters.contains(name); 49 | } 50 | 51 | /// Retrieves a filter function by its name. 52 | /// 53 | /// [name] The name of the filter to retrieve. 54 | /// 55 | /// Returns the filter function if found, or null if not found. 56 | static FilterFunction? getFilter(String name) { 57 | if (_filters.containsKey(name)) { 58 | return _filters[name]; 59 | } 60 | // search in modules first 61 | for (var module in modules.values) { 62 | if (module.filters.containsKey(name)) { 63 | return module.filters[name]; 64 | } 65 | } 66 | return null; 67 | } 68 | 69 | /// Registers a new module with the given name. 70 | /// 71 | /// [name] The name of the module to be registered. 72 | /// [module] The module to be associated with the given name. 73 | static void registerModule(String name, Module module) { 74 | module.register(); 75 | modules[name] = module; 76 | } 77 | 78 | static void initModules() { 79 | for (var module in modules.values) { 80 | module.register(); 81 | } 82 | } 83 | 84 | /// Returns a list of all registered filter names from the global registry. 85 | /// This includes both directly registered filters and module filters. 86 | static List getRegisteredFilterNames() { 87 | final filters = {}; 88 | 89 | // Add directly registered filters 90 | filters.addAll(_filters.keys); 91 | 92 | // Add module filters 93 | for (var module in modules.values) { 94 | filters.addAll(module.filters.keys); 95 | } 96 | 97 | return filters.toList(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /test/analyzer/analyzer/deeply_nested_super_call_test.dart: -------------------------------------------------------------------------------- 1 | // File: test/analyzer/deeply_nested_super_call_test.dart 2 | import 'package:liquify/parser.dart'; 3 | import 'package:liquify/src/analyzer/template_analyzer.dart'; 4 | import 'package:liquify/src/util.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import '../../shared_test_root.dart'; 8 | 9 | void main() { 10 | group('Deeply Nested Super Call', () { 11 | late TestRoot root; 12 | late TemplateAnalyzer analyzer; 13 | 14 | setUp(() { 15 | // Configure logging - enable only analyzer logs 16 | Logger.disableAllContexts(); 17 | // Logger.enableContext('Analyzer'); 18 | root = TestRoot(); 19 | analyzer = TemplateAnalyzer(root); 20 | 21 | // Grandparent template: defines "header" with a nested "navigation" block. 22 | root.addFile('grandparent.liquid', ''' 23 | {% block header %} 24 |
25 | {% block navigation %} 26 |
    27 |
  • Grandparent Nav
  • 28 |
29 | {% endblock %} 30 |
31 | {% endblock %} 32 | '''); 33 | 34 | // Parent template: extends grandparent and overrides the nested "navigation" block. 35 | root.addFile('parent.liquid', ''' 36 | {% layout 'grandparent.liquid' %} 37 | {% block navigation %} 38 |
    39 |
  • Parent Nav
  • 40 |
41 | {% endblock %} 42 | '''); 43 | 44 | // Child template: extends parent and overrides the nested "navigation" block calling super(). 45 | root.addFile('child.liquid', ''' 46 | {% layout 'parent.liquid' %} 47 | {% block navigation %} 48 |
    49 |
  • Child Nav Before
  • 50 | {{ super() }} 51 |
  • Child Nav After
  • 52 |
53 | {% endblock %} 54 | '''); 55 | }); 56 | 57 | test('merges deeply nested super calls', () async { 58 | final analysis = analyzer.analyzeTemplate('child.liquid').last; 59 | final childStructure = analysis.structures['child.liquid']!; 60 | final resolvedBlocks = childStructure.resolvedBlocks; 61 | 62 | // Since "navigation" is originally nested in "header", the final key for the overridden block is "header.navigation". 63 | final navBlock = resolvedBlocks['header.navigation']; 64 | expect(navBlock, isNotNull, 65 | reason: 'header.navigation block should be present.'); 66 | expect(navBlock!.source, equals('child.liquid'), 67 | reason: 'Child override should be used for the nested block.'); 68 | expect(navBlock.isOverride, isTrue, 69 | reason: 'The nested block override must be marked as an override.'); 70 | expect(navBlock.hasSuperCall, isTrue, 71 | reason: 'The nested block should detect a super() call.'); 72 | 73 | // Verify that the nested block's parent is set and comes from parent.liquid. 74 | expect(navBlock.parent, isNotNull, 75 | reason: 'The deeply nested override should have a parent block.'); 76 | expect(navBlock.parent!.source, equals('parent.liquid'), 77 | reason: 78 | 'The parent block for the nested override should be from parent.liquid.'); 79 | 80 | // Additionally, check that at least one node in the block's content is a Tag named "super". 81 | bool foundSuper = 82 | (navBlock.content ?? []).any((n) => n is Tag && n.name == 'super'); 83 | expect(foundSuper, isTrue, 84 | reason: 85 | 'The deeply nested block content should include a super() call tag.'); 86 | }); 87 | }); 88 | } 89 | -------------------------------------------------------------------------------- /test/tags/unless_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('Unless Tag', () { 19 | group('sync evaluation', () { 20 | test('renders when false', () async { 21 | await testParser( 22 | '{% unless product.title == "Awesome Shoes" %}These shoes are not awesome.{% endunless %}', 23 | (document) { 24 | evaluator.context.setVariable('product', {'title': 'Terrible Shoes'}); 25 | evaluator.evaluateNodes(document.children); 26 | expect(evaluator.buffer.toString(), 'These shoes are not awesome.'); 27 | }); 28 | }); 29 | 30 | test('doesnt render when true', () async { 31 | await testParser( 32 | '{% unless product.title == "Awesome Shoes" %}These shoes are not awesome.{% endunless %}', 33 | (document) { 34 | evaluator.context.setVariable('product', {'title': 'Awesome Shoes'}); 35 | evaluator.evaluateNodes(document.children); 36 | expect(evaluator.buffer.toString(), ''); 37 | }); 38 | }); 39 | 40 | test('handles nested unless', () async { 41 | await testParser(''' 42 | {% unless product.title == "Awesome Shoes" %} 43 | {% unless product.price > 100 %} 44 | Affordable non-awesome shoes! 45 | {% endunless %} 46 | {% endunless %} 47 | ''', (document) { 48 | evaluator.context 49 | .setVariable('product', {'title': 'Terrible Shoes', 'price': 50}); 50 | evaluator.evaluateNodes(document.children); 51 | expect(evaluator.buffer.toString().trim(), 52 | 'Affordable non-awesome shoes!'); 53 | }); 54 | }); 55 | }); 56 | 57 | group('async evaluation', () { 58 | test('renders when false', () async { 59 | await testParser( 60 | '{% unless product.title == "Awesome Shoes" %}These shoes are not awesome.{% endunless %}', 61 | (document) async { 62 | evaluator.context.setVariable('product', {'title': 'Terrible Shoes'}); 63 | await evaluator.evaluateNodesAsync(document.children); 64 | expect(evaluator.buffer.toString(), 'These shoes are not awesome.'); 65 | }); 66 | }); 67 | 68 | test('doesnt render when true', () async { 69 | await testParser( 70 | '{% unless product.title == "Awesome Shoes" %}These shoes are not awesome.{% endunless %}', 71 | (document) async { 72 | evaluator.context.setVariable('product', {'title': 'Awesome Shoes'}); 73 | await evaluator.evaluateNodesAsync(document.children); 74 | expect(evaluator.buffer.toString(), ''); 75 | }); 76 | }); 77 | 78 | test('handles nested unless', () async { 79 | await testParser(''' 80 | {% unless product.title == "Awesome Shoes" %} 81 | {% unless product.price > 100 %} 82 | Affordable non-awesome shoes! 83 | {% endunless %} 84 | {% endunless %} 85 | ''', (document) async { 86 | evaluator.context 87 | .setVariable('product', {'title': 'Terrible Shoes', 'price': 50}); 88 | await evaluator.evaluateNodesAsync(document.children); 89 | expect(evaluator.buffer.toString().trim(), 90 | 'Affordable non-awesome shoes!'); 91 | }); 92 | }); 93 | }); 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /lib/src/tags/cycle.dart: -------------------------------------------------------------------------------- 1 | import '../../parser.dart'; 2 | 3 | class CycleTag extends AbstractTag with CustomTagParser, AsyncTag { 4 | late List items; 5 | String? groupName; 6 | 7 | CycleTag(super.content, super.filters); 8 | 9 | @override 10 | void preprocess(Evaluator evaluator) { 11 | if (content.isEmpty) { 12 | throw Exception('CycleTag requires at least one argument.'); 13 | } 14 | 15 | final firstNamedArg = namedArgs.firstOrNull; 16 | 17 | if (firstNamedArg != null) { 18 | groupName = firstNamedArg.identifier.name; 19 | final array = (firstNamedArg.value as Literal).value as List; 20 | items = array.map((e) { 21 | // Evaluate each literal in the array 22 | if (e is Literal) { 23 | return e.value; 24 | } 25 | return evaluator.evaluate(e); 26 | }).toList(); 27 | } else { 28 | items = content 29 | .where((e) => e is Identifier || e is Literal) 30 | .map((e) => evaluator.evaluate(e)) 31 | .toList(); 32 | } 33 | 34 | if (items.isEmpty) { 35 | throw Exception('CycleTag requires at least one item to cycle through.'); 36 | } 37 | } 38 | 39 | Map _getCycleState(Evaluator evaluator) { 40 | final key = _getStateKey(); 41 | final existingState = 42 | evaluator.context.getVariable(key) as Map?; 43 | return existingState ?? {'index': 0}; 44 | } 45 | 46 | void _setCycleState(Evaluator evaluator, Map state) { 47 | final key = _getStateKey(); 48 | evaluator.context.setVariable(key, state); 49 | } 50 | 51 | String _getStateKey() { 52 | return groupName != null ? 'cycle:$groupName' : 'cycle:${items.join(",")}'; 53 | } 54 | 55 | @override 56 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 57 | final cycleState = _getCycleState(evaluator); 58 | final currentIndex = cycleState['index'] as int; 59 | final currentItem = items[currentIndex]; 60 | 61 | buffer.write(currentItem); 62 | 63 | cycleState['index'] = (currentIndex + 1) % items.length; 64 | _setCycleState(evaluator, cycleState); 65 | } 66 | 67 | @override 68 | Future evaluateWithContextAsync( 69 | Evaluator evaluator, Buffer buffer) async { 70 | final cycleState = _getCycleState(evaluator); 71 | final currentIndex = cycleState['index'] as int; 72 | final currentItem = items[currentIndex]; 73 | 74 | buffer.write(currentItem); 75 | 76 | cycleState['index'] = (currentIndex + 1) % items.length; 77 | _setCycleState(evaluator, cycleState); 78 | } 79 | 80 | @override 81 | Parser parser() { 82 | return seq3(tagStart() & string('cycle').trim(), 83 | ref0(cycleArguments).trim(), tagEnd()) 84 | .map((values) { 85 | return Tag( 86 | 'cycle', 87 | values.$2 is List 88 | ? values.$2.cast() 89 | : [values.$2 as ASTNode]); 90 | }); 91 | } 92 | 93 | Parser cycleArguments() { 94 | return ref0(cycleNamedArgument).or(ref0(cycleSimpleArguments)); 95 | } 96 | 97 | Parser cycleNamedArgument() { 98 | return seq3( 99 | ref0(stringLiteral), char(':').trim(), ref0(cycleSimpleArguments)) 100 | .map((values) { 101 | final name = values.$1; 102 | final args = (values.$3 as List).cast(); 103 | return NamedArgument( 104 | Identifier(name.value), Literal(args, LiteralType.array)); 105 | }); 106 | } 107 | 108 | Parser cycleSimpleArguments() { 109 | return ref0(expression) 110 | .plusSeparated(char(',').trim()) 111 | .map((result) => result.elements); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /test/layout_test2.dart: -------------------------------------------------------------------------------- 1 | import 'package:file/memory.dart'; 2 | import 'package:liquify/src/context.dart'; 3 | import 'package:liquify/src/evaluator.dart'; 4 | import 'package:liquify/src/fs.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | import 'shared.dart'; 8 | 9 | void main() { 10 | late Evaluator evaluator; 11 | late MemoryFileSystem fileSystem; 12 | late FileSystemRoot root; 13 | 14 | setUp(() { 15 | evaluator = Evaluator(Environment()); 16 | fileSystem = MemoryFileSystem(); 17 | root = FileSystemRoot('/templates', fileSystem: fileSystem); 18 | evaluator.context.setRoot(root); 19 | 20 | // Set up base layout template 21 | fileSystem.file('/templates/layouts/base.liquid') 22 | ..createSync(recursive: true) 23 | ..writeAsStringSync(''' 24 | 25 | 26 | 27 | {% block title %}Default Title{% endblock %} 28 | 29 | 30 | 31 |
32 | {% block header %}Default Header{% endblock %} 33 |
34 |
35 | {% block content %}Default Content{% endblock %} 36 |
37 |
38 | {% block footer %}Default Footer{% endblock %} 39 |
40 | 41 | 42 | '''); 43 | 44 | // Set up post layout template 45 | fileSystem.file('/templates/layouts/post.liquid') 46 | ..createSync(recursive: true) 47 | ..writeAsStringSync(''' 48 | {% layout "layouts/base.liquid" %} 49 | {% block title %}Post Title{% endblock %} 50 | {% block content %}Post Content{% endblock %} 51 | '''); 52 | 53 | // Set up actual post template 54 | fileSystem.file('/templates/posts/hello-world.liquid') 55 | ..createSync(recursive: true) 56 | ..writeAsStringSync(''' 57 | {% layout "layouts/post.liquid" %} 58 | {% block content %}Hello, World!{% endblock %} 59 | '''); 60 | }); 61 | 62 | tearDown(() { 63 | evaluator.context.clear(); 64 | }); 65 | 66 | group('Nested Layouts', () { 67 | group('sync evaluation', () { 68 | test('nested layout inheritance', () async { 69 | await testParser(''' 70 | {% layout "posts/hello-world.liquid" %} 71 | ''', (document) { 72 | evaluator.evaluateNodes(document.children); 73 | expect( 74 | evaluator.buffer.toString().trim(), 75 | ''' 76 | 77 | 78 | 79 | Post Title 80 | 81 | 82 | 83 |
84 | Default Header 85 |
86 |
87 | Hello, World! 88 |
89 |
90 | Default Footer 91 |
92 | 93 | 94 | ''' 95 | .trim()); 96 | }); 97 | }); 98 | }); 99 | 100 | // group('async evaluation', () { 101 | // test('nested layout inheritance', () async { 102 | // await testParser(''' 103 | // {% layout "posts/hello-world.liquid" %} 104 | // ''', (document) async { 105 | // await evaluator.evaluateNodesAsync(document.children); 106 | // expect(evaluator.buffer.toString().trim(), ''' 107 | // 108 | // 109 | // 110 | // Post Title 111 | // 112 | // 113 | // 114 | //
115 | // Default Header 116 | //
117 | //
118 | // Hello, World! 119 | //
120 | //
121 | // Default Footer 122 | //
123 | // 124 | // 125 | // '''.trim()); 126 | // }); 127 | // }); 128 | // }); 129 | }); 130 | } 131 | -------------------------------------------------------------------------------- /docs/tags/README.md: -------------------------------------------------------------------------------- 1 | # Tags Documentation 2 | 3 | Tags are the logic elements of Liquid templates. They control the flow of the template, perform iterations, create variables, and handle layout rendering. 4 | 5 | ## Tag Categories 6 | 7 | ### Control Flow Tags 8 | Control the execution flow of your templates with conditional logic. 9 | 10 | - **[if](control-flow.md#if)** - Conditional execution 11 | - **[unless](control-flow.md#unless)** - Negative conditional execution 12 | - **[case](control-flow.md#case)** - Multi-way conditional execution 13 | 14 | ### Iteration Tags 15 | Loop through arrays, objects, and ranges to generate repetitive content. 16 | 17 | - **[for](iteration.md#for)** - Standard iteration with advanced features 18 | - **[tablerow](iteration.md#tablerow)** - Generate HTML table rows 19 | - **[cycle](iteration.md#cycle)** - Cycle through values 20 | - **[repeat](iteration.md#repeat)** - Repeat content a specific number of times 21 | 22 | ### Variable Tags 23 | Create, modify, and capture variables within your templates. 24 | 25 | - **[assign](variables.md#assign)** - Create variables 26 | - **[capture](variables.md#capture)** - Capture template output into variables 27 | - **[increment](variables.md#increment)** - Increment numeric variables 28 | - **[decrement](variables.md#decrement)** - Decrement numeric variables 29 | 30 | ### Layout & Rendering Tags 31 | Handle template composition, inheritance, and partial rendering. 32 | 33 | - **[layout](layout.md#layout)** - Define layout templates 34 | - **[block](layout.md#block)** - Define replaceable content blocks 35 | - **[super](layout.md#super)** - Call parent block content 36 | - **[render](layout.md#render)** - Include and render other templates 37 | 38 | ### Output Tags 39 | Control how content is output and processed. 40 | 41 | - **[echo](output.md#echo)** - Output expressions (same as `{{ }}`) 42 | - **[liquid](output.md#liquid)** - Compact tag syntax 43 | 44 | ### Utility Tags 45 | Utility tags for content handling and flow control. 46 | 47 | - **[raw](utility.md#raw)** - Output content without processing 48 | - **[comment](utility.md#comment)** - Add comments (not rendered) 49 | - **[break](utility.md#break)** - Exit loops early 50 | - **[continue](utility.md#continue)** - Skip to next iteration 51 | 52 | ## Syntax Overview 53 | 54 | ### Block Tags 55 | Most tags are block tags that wrap content: 56 | 57 | ```liquid 58 | {% tagname parameters %} 59 | content 60 | {% endtagname %} 61 | ``` 62 | 63 | ### Inline Tags 64 | Some tags are inline and don't require closing: 65 | 66 | ```liquid 67 | {% tagname parameters %} 68 | ``` 69 | 70 | ### Parameters 71 | Tags can accept various types of parameters: 72 | 73 | ```liquid 74 | {% for item in array limit: 5 offset: 2 %} 75 | {% assign name = "value" %} 76 | {% if condition == true %} 77 | ``` 78 | 79 | ## Common Patterns 80 | 81 | ### Combining Tags 82 | Tags can be nested and combined for complex logic: 83 | 84 | ```liquid 85 | {% for product in products %} 86 | {% if product.available %} 87 | {% assign discounted_price = product.price | times: 0.9 %} 88 |
89 |

{{ product.title }}

90 |

Price: ${{ discounted_price }}

91 |
92 | {% endif %} 93 | {% endfor %} 94 | ``` 95 | 96 | ### Error Handling 97 | Most tags handle missing data gracefully: 98 | 99 | ```liquid 100 | {% for item in missing_array %} 101 | This won't execute if missing_array is null 102 | {% else %} 103 | This will execute instead 104 | {% endfor %} 105 | ``` 106 | 107 | ## Async Support 108 | 109 | All tags support both synchronous and asynchronous evaluation: 110 | 111 | ```dart 112 | // Synchronous 113 | evaluator.evaluateNodes(document.children); 114 | 115 | // Asynchronous 116 | await evaluator.evaluateNodesAsync(document.children); 117 | ``` -------------------------------------------------------------------------------- /test/tags/assign_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('AssignTag', () { 19 | group('sync evaluation', () { 20 | test('assigns simple value', () async { 21 | await testParser(''' 22 | {% assign my_variable = "hello" %} 23 | {{ my_variable }} 24 | ''', (document) { 25 | evaluator.evaluateNodes(document.children); 26 | expect(evaluator.buffer.toString().trim(), equals('hello')); 27 | }); 28 | }); 29 | 30 | test('assigns value with filter', () async { 31 | await testParser(''' 32 | {% assign my_variable = "hello" | upcase %} 33 | {{ my_variable }} 34 | ''', (document) { 35 | evaluator.evaluateNodes(document.children); 36 | expect(evaluator.buffer.toString().trim(), equals('HELLO')); 37 | }); 38 | }); 39 | 40 | test('assigns result of expression', () async { 41 | await testParser(''' 42 | {% assign x = 2 %} 43 | {% assign result = x | plus: 3 %} 44 | {{ result }} 45 | ''', (document) { 46 | evaluator.evaluateNodes(document.children); 47 | expect(evaluator.buffer.toString().trim(), equals('5')); 48 | }); 49 | }); 50 | 51 | test('assigns with multiple filters', () async { 52 | await testParser(''' 53 | {% assign my_variable = "hello world" | capitalize | split: " " | first %} 54 | {{ my_variable }} 55 | ''', (document) { 56 | evaluator.evaluateNodes(document.children); 57 | expect(evaluator.buffer.toString().trim(), equals('Hello')); 58 | }); 59 | }); 60 | }); 61 | 62 | group('async evaluation', () { 63 | test('assigns simple value', () async { 64 | await testParser(''' 65 | {% assign my_variable = "hello" %} 66 | {{ my_variable }} 67 | ''', (document) async { 68 | await evaluator.evaluateNodesAsync(document.children); 69 | expect(evaluator.buffer.toString().trim(), equals('hello')); 70 | }); 71 | }); 72 | 73 | test('assigns value with filter', () async { 74 | await testParser(''' 75 | {% assign my_variable = "hello" | upcase %} 76 | {{ my_variable }} 77 | ''', (document) async { 78 | await evaluator.evaluateNodesAsync(document.children); 79 | expect(evaluator.buffer.toString().trim(), equals('HELLO')); 80 | }); 81 | }); 82 | 83 | test('assigns result of expression', () async { 84 | await testParser(''' 85 | {% assign x = 2 %} 86 | {% assign result = x | plus: 3 %} 87 | {{ result }} 88 | ''', (document) async { 89 | await evaluator.evaluateNodesAsync(document.children); 90 | expect(evaluator.buffer.toString().trim(), equals('5')); 91 | }); 92 | }); 93 | 94 | test('assigns with multiple filters', () async { 95 | await testParser(''' 96 | {% assign my_variable = "hello world" | capitalize | split: " " | first %} 97 | {{ my_variable }} 98 | ''', (document) async { 99 | await evaluator.evaluateNodesAsync(document.children); 100 | expect(evaluator.buffer.toString().trim(), equals('Hello')); 101 | }); 102 | }); 103 | }); 104 | }); 105 | } 106 | -------------------------------------------------------------------------------- /test/tags/liquid_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | group('Liquid Tag', () { 18 | group('sync evaluation', () { 19 | test('assigns variable', () async { 20 | await testParser(''' 21 | {% liquid 22 | assign my_variable = "string" 23 | %} 24 | ''', (document) { 25 | evaluator.evaluateNodes(document.children); 26 | expect(evaluator.context.getVariable('my_variable'), 'string'); 27 | }); 28 | }); 29 | 30 | test('ignores single-line comment', () async { 31 | await testParser(''' 32 | {% liquid 33 | {# This is a single-line comment #} 34 | assign my_variable = "string" 35 | %} 36 | ''', (document) { 37 | evaluator.evaluateNodes(document.children); 38 | expect(evaluator.context.getVariable('my_variable'), 'string'); 39 | }); 40 | }); 41 | 42 | test('multiple operations', () async { 43 | await testParser(''' 44 | {% liquid 45 | assign x = 5 46 | assign y = x | plus: 3 47 | assign z = y | times: 2 48 | %} 49 | {{ z }} 50 | ''', (document) { 51 | evaluator.evaluateNodes(document.children); 52 | expect(evaluator.buffer.toString().trim(), '16'); 53 | }); 54 | }); 55 | 56 | test('ignores multi-line comment', () async { 57 | await testParser(''' 58 | {% liquid 59 | ############################### 60 | # This is a comment 61 | # across multiple lines 62 | ############################### 63 | assign my_variable = "string" 64 | %} 65 | ''', (document) { 66 | evaluator.evaluateNodes(document.children); 67 | expect(evaluator.context.getVariable('my_variable'), 'string'); 68 | }); 69 | }); 70 | }); 71 | 72 | group('async evaluation', () { 73 | test('assigns variable', () async { 74 | await testParser(''' 75 | {% liquid 76 | assign my_variable = "string" 77 | %} 78 | ''', (document) async { 79 | await evaluator.evaluateNodesAsync(document.children); 80 | expect(evaluator.context.getVariable('my_variable'), 'string'); 81 | }); 82 | }); 83 | 84 | test('multiple operations', () async { 85 | await testParser(''' 86 | {% liquid 87 | assign x = 5 88 | assign y = x | plus: 3 89 | assign z = y | times: 2 90 | %} 91 | {{ z }} 92 | ''', (document) async { 93 | await evaluator.evaluateNodesAsync(document.children); 94 | expect(evaluator.buffer.toString().trim(), '16'); 95 | }); 96 | }); 97 | 98 | test('ignores single-line comment', () async { 99 | await testParser(''' 100 | {% liquid 101 | # This is a single-line comment 102 | assign my_variable = "string" 103 | %} 104 | ''', (document) async { 105 | await evaluator.evaluateNodesAsync(document.children); 106 | expect(evaluator.context.getVariable('my_variable'), 'string'); 107 | }); 108 | }); 109 | 110 | test('ignores multi-line comment', () async { 111 | await testParser(''' 112 | {% 113 | ############################### 114 | # This is a comment 115 | # across multiple lines 116 | ############################### 117 | %} 118 | ''', (document) async { 119 | await evaluator.evaluateNodesAsync(document.children); 120 | expect(evaluator.buffer.toString().trim(), ''); 121 | }); 122 | }); 123 | }); 124 | }); 125 | } 126 | -------------------------------------------------------------------------------- /example/layout.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/liquify.dart'; 2 | 3 | void main() async { 4 | // Create our file system with templates 5 | final fs = MapRoot({ 6 | // Base layout with common structure 7 | 'layouts/base.liquid': ''' 8 | 9 | 10 | 11 | {% block title %}Default Title{% endblock %} 12 | {% block meta %}{% endblock %} 13 | 14 | {% block styles %}{% endblock %} 15 | 16 | 17 |
18 | {% block header %} 19 | 24 | {% endblock %} 25 |
26 | 27 |
28 | {% block content %} 29 | Default content 30 | {% endblock %} 31 |
32 | 33 |
34 | {% block footer %} 35 |

© {{ year }} My Website

36 | {% endblock %} 37 |
38 | 39 | 40 | {% block scripts %}{% endblock %} 41 | 42 | ''', 43 | 44 | // Blog post layout that extends base 45 | 'layouts/post.liquid': ''' 46 | {% layout "layouts/base.liquid", title: post_title, year: year %} 47 | 48 | {% block meta %} 49 | 50 | 51 | {% endblock %} 52 | 53 | {% block styles %} 54 | 55 | {% endblock %} 56 | 57 | {% block content %} 58 |
59 |

{{ post_title }}

60 | 63 |
64 | {{ post.content }} 65 |
66 | {% if post.tags.size > 0 %} 67 |
68 | Tags: 69 | {% for tag in post.tags %} 70 | {{ tag }} 71 | {% endfor %} 72 |
73 | {% endif %} 74 |
75 | {% endblock %} 76 | 77 | {% block scripts %} 78 | 79 | {% endblock %}''', 80 | 81 | // Actual blog post using post layout 82 | 'posts/hello-world.liquid': ''' 83 | {% assign post_title = "Hello, World!" %} 84 | {% layout "layouts/post.liquid", post_title: post_title, year: year %} 85 | {%- block header -%} 86 |

HEADER CONTENT

87 | {%- endblock -%} 88 | {% block footer %} 89 | {{ block.parent }} 90 | 93 | {% endblock %}''' 94 | }); 95 | 96 | // Sample post data 97 | final context = { 98 | 'year': 2024, 99 | 'post': { 100 | 'title': 'Hello, World!', 101 | 'author': 'John Doe', 102 | 'date': '2024-02-09', 103 | 'excerpt': 'An introduction to our blog', 104 | 'content': ''' 105 | Welcome to our new blog! This is our first post exploring the features 106 | of the Liquid template engine. We'll be covering: 107 | 108 | - Template inheritance 109 | - Layout blocks 110 | - Custom filters 111 | - And much more! 112 | 113 | Stay tuned for more content coming soon!''', 114 | 'tags': ['welcome', 'introduction', 'liquid'], 115 | } 116 | }; 117 | 118 | print('\nRendering blog post with layout inheritance:'); 119 | print('----------------------------------------\n'); 120 | 121 | // Render the blog post 122 | final template = 123 | Template.fromFile('posts/hello-world.liquid', fs, data: context); 124 | print(await template.renderAsync()); 125 | 126 | // Demonstrate dynamic layout names 127 | print('\nDemo of dynamic layout names:'); 128 | print('----------------------------------------\n'); 129 | 130 | final dynamicTemplate = Template.parse( 131 | '{% layout "layouts/{{ layout_type }}.liquid", title: "Dynamic Title" %}', 132 | data: { 133 | 'layout_type': 'post', 134 | }, 135 | root: fs); 136 | print(await dynamicTemplate.renderAsync()); 137 | } 138 | -------------------------------------------------------------------------------- /lib/src/drop.dart: -------------------------------------------------------------------------------- 1 | import 'package:gato/gato.dart' as gato; 2 | 3 | extension SymbolExtension on Symbol { 4 | /// Retrieves the name of the Symbol without the "Symbol(\"" prefix and the trailing "\"". 5 | /// 6 | /// This extension method is defined on the Symbol type to provide a convenient way to get the 7 | /// name of a Symbol without the boilerplate of string manipulation. 8 | String get name { 9 | var name = toString(); 10 | return toString() 11 | .replaceRange(name.length - 2, name.length, '') 12 | .replaceAll("Symbol(\"", ''); 13 | } 14 | } 15 | 16 | /// The `Drop` class is an abstract class that provides a way to dynamically 17 | /// invoke methods on an object. It maintains a list of invokable methods 18 | /// (`invokable`) and a map of attributes (`attrs`). The `call` method is used 19 | /// to invoke methods on the `Drop` object, and the `liquidMethodMissing` method 20 | /// can be overridden to provide custom handling for missing methods. 21 | abstract class Drop { 22 | List invokable = []; 23 | Map attrs = {}; 24 | 25 | /// Handles the case where a method is missing from the `Drop` object. 26 | /// 27 | /// This method is called when a method is invoked on the `Drop` object that is not 28 | /// present in the `invokable` list. The default implementation returns `null`, but 29 | /// this method can be overridden in subclasses to provide custom handling for 30 | /// missing methods. 31 | /// 32 | /// @param method The Symbol representing the missing method. 33 | /// @return The result of handling the missing method, or `null` by default. 34 | dynamic liquidMethodMissing(Symbol method) => null; 35 | 36 | /// Invokes the method represented by the provided [attr] Symbol. 37 | /// 38 | /// This method first checks if the [attrs] map contains a value for the given [attr] name. 39 | /// If a value is found, it is returned. 40 | /// 41 | /// If the [invokable] list is not empty and contains the [attr] Symbol, the [invoke] method 42 | /// is called to execute the corresponding method. 43 | /// 44 | /// If neither of the above conditions are met, the [liquidMethodMissing] method is called 45 | /// to handle the missing method. 46 | dynamic call(Symbol attr) { 47 | if (get(attr.name) != null) return get(attr.name); 48 | if (invokable.isNotEmpty && invokable.contains(attr)) { 49 | return invoke(attr); 50 | } 51 | 52 | return liquidMethodMissing(attr); 53 | } 54 | 55 | /// Retrieves the value associated with the given [path] from the [attrs] map. 56 | /// 57 | /// This method is a helper for accessing values in the [attrs] map. It delegates 58 | /// the lookup to the [gato.get] function, which provides a convenient way to 59 | /// retrieve nested values from a map. 60 | dynamic operator [](String path) { 61 | return get(path); 62 | } 63 | 64 | /// Invokes the method represented by the provided [Symbol]. 65 | /// 66 | /// This method is used to dynamically invoke a method on the `Drop` instance. 67 | /// If the method is present in the `invokable` list, it will be executed. 68 | /// Otherwise, the `liquidMethodMissing` method will be called to handle the 69 | /// missing method. 70 | dynamic invoke(Symbol symbol) { 71 | return null; 72 | } 73 | 74 | /// Invokes the method represented by the provided [Symbol]. 75 | /// 76 | /// This method is used to dynamically invoke a method on the `Drop` instance. 77 | /// If the method is present in the `invokable` list, it will be executed. 78 | /// Otherwise, the `liquidMethodMissing` method will be called to handle the 79 | /// missing method. 80 | dynamic exec(Symbol method) { 81 | return this(method); 82 | } 83 | 84 | /// Retrieves the value associated with the given [path] from the [attrs] map. 85 | /// 86 | /// This method is a helper for accessing values in the [attrs] map. It delegates 87 | /// the lookup to the [gato.get] function, which provides a convenient way to 88 | /// retrieve nested values from a map. 89 | dynamic get(String path) { 90 | return gato.get(attrs, path); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /lib/parser.dart: -------------------------------------------------------------------------------- 1 | /// Liquify Parser: Core parsing, tag management, and registry for Liquid templates 2 | /// 3 | /// This library provides essential components for parsing Liquid templates, 4 | /// managing custom tags, and registering built-in and custom functionality. 5 | /// 6 | /// Key components: 7 | /// 8 | /// 1. Parsing and Grammar (in `src/grammar/shared.dart`): 9 | /// - Basic Liquid syntax parsers: [tagStart], [tagEnd], [varStart], [varEnd] 10 | /// - Expression parsers: [expression], [literal], [identifier], [memberAccess] 11 | /// - Tag helpers: [someTag], [breakTag], [continueTag], [elseTag] 12 | /// - Main parsing function: [parseInput] 13 | /// 14 | /// Note: The [someTag] helper is designed for simple tags without end tags. 15 | /// For tags that require an end tag or more complex parsing, custom parser 16 | /// implementation is necessary. 17 | /// 18 | /// 2. Tag Management (in `src/tags/tag.dart`): 19 | /// - [AbstractTag]: Base class for all Liquid tags 20 | /// - [Tag]: Standard implementation of a Liquid tag 21 | /// 22 | /// 3. Tag Registry (in `src/registry.dart`): 23 | /// - [TagRegistry]: Central management system for Liquid template tags 24 | /// - [TagRegistry.register]: Register new custom tags 25 | /// - [TagRegistry.createTag]: Create instances of registered tags 26 | /// - [registerBuiltIns]: Function to register all built-in Liquid tags 27 | /// 28 | /// 4. AST (Abstract Syntax Tree) (in `src/ast.dart`): 29 | /// - Node types for representing parsed Liquid templates 30 | /// - Includes [ASTNode], [TextNode], [Variable], [Filter], etc. 31 | /// 32 | /// 5. Evaluation (in `src/evaluator.dart`): 33 | /// - [Evaluator]: Class for evaluating parsed Liquid templates 34 | /// - [Evaluator.evaluate]: Evaluates a single ASTNode 35 | /// - [Evaluator.evaluateNodes]: Evaluates a list of ASTNodes 36 | /// 37 | /// Usage: 38 | /// ```dart 39 | /// import 'package:liquify/parser.dart'; 40 | /// 41 | /// // Define a custom tag with an end tag 42 | /// class UppercaseTag extends AbstractTag with CustomTagParser { 43 | /// UppercaseTag(List content, List filters) : super(content, filters); 44 | /// 45 | /// @override 46 | /// dynamic evaluate(Evaluator evaluator, Buffer buffer) { 47 | /// buffer.write(''); 48 | /// for (final node in content) { 49 | /// final result = evaluator.evaluate(node); 50 | /// buffer.write(result); 51 | /// } 52 | /// buffer.write(''); 53 | /// } 54 | /// 55 | /// @override 56 | /// Parser parser() { 57 | /// // Custom parser implementation for tags with end tags 58 | /// return (tagStart() & 59 | /// string('uppercase').trim() & 60 | /// tagEnd() & 61 | /// any() 62 | /// .starLazy(tagStart() & string('enduppercase').trim() & tagEnd()) 63 | /// .flatten() & 64 | /// tagStart() & 65 | /// string('enduppercase').trim() & 66 | /// tagEnd()) 67 | /// .map((values) { 68 | /// return Tag("uppercase", [TextNode(values[3])]); 69 | /// }); 70 | /// } 71 | /// } 72 | /// 73 | /// // For a simple tag without an end tag, you could use someTag: 74 | /// // Parser simpleParser() => someTag('simpletag'); 75 | /// 76 | /// // Register the custom tag 77 | /// TagRegistry.register('uppercase', (content, filters) => UppercaseTag(content, filters)); 78 | /// 79 | /// // Parse a template using the custom tag 80 | /// List nodes = parseInput('{% uppercase %}Hello, {{ name }}!{% enduppercase %}'); 81 | /// 82 | /// // Evaluate the template 83 | /// Evaluator evaluator = Evaluator(Environment({'name': 'World'})); 84 | /// final result = evaluator.evaluateNodes(nodes); 85 | /// print(result); // Output: Hello, World! 86 | /// ``` 87 | /// 88 | /// This library is used internally by the Liquify engine but can also be 89 | /// used directly for advanced customization of the Liquid parsing and 90 | /// evaluation process. 91 | library; 92 | 93 | export 'package:liquify/src/tag.dart'; 94 | export 'package:liquify/src/tag_registry.dart'; 95 | export 'package:liquify/src/context.dart'; 96 | -------------------------------------------------------------------------------- /test/tags/increment_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('IncrementTag', () { 19 | group('sync evaluation', () { 20 | test('starts from 0 and increments', () async { 21 | await testParser(''' 22 | {% increment counter %} 23 | {% increment counter %} 24 | {% increment counter %} 25 | ''', (document) { 26 | evaluator.evaluateNodes(document.children); 27 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 28 | '012'); 29 | }); 30 | }); 31 | 32 | test('is independent from assign', () async { 33 | await testParser(''' 34 | {% assign counter = 42 %} 35 | {% increment counter %} 36 | {{ counter }} 37 | ''', (document) { 38 | evaluator.evaluateNodes(document.children); 39 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 40 | '042'); 41 | }); 42 | }); 43 | 44 | test('shares state with decrement', () async { 45 | await testParser(''' 46 | {% increment counter %} 47 | {% decrement counter %} 48 | {% increment counter %} 49 | ''', (document) { 50 | evaluator.evaluateNodes(document.children); 51 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 52 | '0-10'); 53 | }); 54 | }); 55 | 56 | test('maintains separate counters', () async { 57 | await testParser(''' 58 | {% increment counter1 %} 59 | {% increment counter2 %} 60 | {% increment counter1 %} 61 | {% increment counter2 %} 62 | ''', (document) { 63 | evaluator.evaluateNodes(document.children); 64 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 65 | '0011'); 66 | }); 67 | }); 68 | }); 69 | 70 | group('async evaluation', () { 71 | test('starts from 0 and increments', () async { 72 | await testParser(''' 73 | {% increment counter %} 74 | {% increment counter %} 75 | {% increment counter %} 76 | ''', (document) async { 77 | await evaluator.evaluateNodesAsync(document.children); 78 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 79 | '012'); 80 | }); 81 | }); 82 | 83 | test('is independent from assign', () async { 84 | await testParser(''' 85 | {% assign counter = 42 %} 86 | {% increment counter %} 87 | {{ counter }} 88 | ''', (document) async { 89 | await evaluator.evaluateNodesAsync(document.children); 90 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 91 | '042'); 92 | }); 93 | }); 94 | 95 | test('shares state with decrement', () async { 96 | await testParser(''' 97 | {% increment counter %} 98 | {% decrement counter %} 99 | {% increment counter %} 100 | ''', (document) async { 101 | await evaluator.evaluateNodesAsync(document.children); 102 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 103 | '0-10'); 104 | }); 105 | }); 106 | 107 | test('maintains separate counters', () async { 108 | await testParser(''' 109 | {% increment counter1 %} 110 | {% increment counter2 %} 111 | {% increment counter1 %} 112 | {% increment counter2 %} 113 | ''', (document) async { 114 | await evaluator.evaluateNodesAsync(document.children); 115 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 116 | '0011'); 117 | }); 118 | }); 119 | }); 120 | }); 121 | } 122 | -------------------------------------------------------------------------------- /lib/src/tag_registry.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/ast.dart' show ASTNode, Filter; 2 | import 'package:liquify/src/mixins/parser.dart' show CustomTagParser; 3 | import 'package:liquify/src/tags/tags.dart' as tags; 4 | 5 | import 'tags/tag.dart'; 6 | 7 | /// TagRegistry is responsible for managing and creating Liquid template tags. 8 | class TagRegistry { 9 | /// Map of tag names to their creator functions. 10 | static final Map, List)> _tags = {}; 11 | 12 | /// Map of tag names to their custom parser instances. 13 | static final Map _customTagParsers = {}; 14 | 15 | /// Returns a list of all registered custom tag parsers. 16 | static List get customParsers => 17 | _customTagParsers.entries.map((p) => p.value).toList(); 18 | 19 | /// Registers a new tag with the given name and creator function. 20 | /// 21 | /// If the created tag implements CustomTagParser, it's also added to _customTagParsers. 22 | static void register( 23 | String name, Function(List, List) creator) { 24 | _tags[name] = creator; 25 | 26 | final creatorInstance = creator([].cast(), [].cast()); 27 | if (creatorInstance is CustomTagParser) { 28 | _customTagParsers[name] = creatorInstance; 29 | } 30 | } 31 | 32 | /// Creates a tag instance with the given name, content, and filters. 33 | /// 34 | /// Returns null if the tag is not registered. 35 | static AbstractTag? createTag( 36 | String name, List content, List filters) { 37 | final creator = _tags[name]; 38 | if (creator != null) { 39 | return creator(content, filters); 40 | } 41 | return null; 42 | } 43 | 44 | /// Returns a list of all registered tag names. 45 | static List get tags => _tags.keys.toList(); 46 | } 47 | 48 | /// Registers all built-in Liquid tags. 49 | void registerBuiltInTags() { 50 | TagRegistry.register( 51 | 'layout', (content, filters) => tags.LayoutTag(content, filters)); 52 | TagRegistry.register( 53 | 'super', (content, filters) => tags.SuperTag(content, filters)); 54 | TagRegistry.register( 55 | 'block', (content, filters) => tags.BlockTag(content, filters)); 56 | TagRegistry.register( 57 | 'echo', (content, filters) => tags.EchoTag(content, filters)); 58 | TagRegistry.register( 59 | 'assign', (content, filters) => tags.AssignTag(content, filters)); 60 | TagRegistry.register( 61 | 'increment', (content, filters) => tags.IncrementTag(content, filters)); 62 | TagRegistry.register( 63 | 'decrement', (content, filters) => tags.DecrementTag(content, filters)); 64 | TagRegistry.register( 65 | 'repeat', (content, filters) => tags.RepeatTag(content, filters)); 66 | TagRegistry.register( 67 | 'for', (content, filters) => tags.ForTag(content, filters)); 68 | TagRegistry.register( 69 | 'if', (content, filters) => tags.IfTag(content, filters)); 70 | TagRegistry.register( 71 | 'elsif', (content, filters) => tags.IfTag(content, filters)); 72 | TagRegistry.register( 73 | 'continue', (content, filters) => tags.ContinueTag(content, filters)); 74 | TagRegistry.register( 75 | 'break', (content, filters) => tags.BreakTag(content, filters)); 76 | TagRegistry.register( 77 | 'cycle', (content, filters) => tags.CycleTag(content, filters)); 78 | TagRegistry.register( 79 | 'tablerow', (content, filters) => tags.TableRowTag(content, filters)); 80 | TagRegistry.register( 81 | 'unless', (content, filters) => tags.UnlessTag(content, filters)); 82 | TagRegistry.register( 83 | 'capture', (content, filters) => tags.CaptureTag(content, filters)); 84 | TagRegistry.register( 85 | 'liquid', (content, filters) => tags.LiquidTag(content, filters)); 86 | TagRegistry.register( 87 | 'case', (content, filters) => tags.CaseTag(content, filters)); 88 | TagRegistry.register( 89 | 'raw', (content, filters) => tags.RawTag(content, filters)); 90 | TagRegistry.register( 91 | 'comment', (content, filters) => tags.CommentTag(content, filters)); 92 | TagRegistry.register( 93 | 'doc', (content, filters) => tags.DocTag(content, filters)); 94 | TagRegistry.register( 95 | 'render', (content, filters) => tags.RenderTag(content, filters)); 96 | } 97 | -------------------------------------------------------------------------------- /test/fs_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:liquify/src/fs.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | class MockRoot extends Root { 7 | final Map templates; 8 | final Duration? delay; 9 | 10 | MockRoot(this.templates, {this.delay}); 11 | 12 | @override 13 | Source resolve(String relPath) { 14 | final content = templates[relPath]; 15 | if (content == null) { 16 | throw Exception('Template not found: $relPath'); 17 | } 18 | return Source(Uri.parse(relPath), content, this); 19 | } 20 | 21 | @override 22 | Future resolveAsync(String relPath) async { 23 | if (delay != null) { 24 | await Future.delayed(delay!); 25 | } 26 | final content = templates[relPath]; 27 | if (content == null) { 28 | throw Exception('Template not found: $relPath'); 29 | } 30 | return Source(Uri.parse(relPath), content, this); 31 | } 32 | } 33 | 34 | void main() { 35 | group('MockRoot', () { 36 | late MockRoot root; 37 | late Environment context; 38 | late Evaluator evaluator; 39 | 40 | setUp(() { 41 | root = MockRoot({ 42 | 'simple.liquid': 'Hello, {{ name }}!', 43 | 'with_vars.liquid': '{{ greeting }}, {{ person }}!', 44 | 'for_loop.liquid': '{% for item in items %}{{ item }} {% endfor %}', 45 | 'nested.liquid': '{% render "simple.liquid" name: "World" %}', 46 | }); 47 | context = Environment()..setRoot(root); 48 | evaluator = Evaluator(context); 49 | }); 50 | 51 | group('sync operations', () { 52 | test('resolves existing template', () { 53 | final source = root.resolve('simple.liquid'); 54 | expect(source.content, equals('Hello, {{ name }}!')); 55 | expect(source.file?.path, equals('simple.liquid')); 56 | }); 57 | 58 | test('throws exception for non-existent template', () { 59 | expect(() => root.resolve('non_existent.liquid'), throwsException); 60 | }); 61 | 62 | test('evaluator can resolve and parse template', () { 63 | final nodes = evaluator.resolveAndParseTemplate('simple.liquid'); 64 | expect(nodes, isNotEmpty); 65 | }); 66 | 67 | test('evaluator can resolve and parse template with variables', () { 68 | final nodes = evaluator.resolveAndParseTemplate('with_vars.liquid'); 69 | expect(nodes, isNotEmpty); 70 | }); 71 | 72 | test('evaluator can resolve and parse template with for loop', () { 73 | final nodes = evaluator.resolveAndParseTemplate('for_loop.liquid'); 74 | expect(nodes, isNotEmpty); 75 | }); 76 | 77 | test('evaluator can resolve and parse nested template', () { 78 | final nodes = evaluator.resolveAndParseTemplate('nested.liquid'); 79 | expect(nodes, isNotEmpty); 80 | }); 81 | }); 82 | 83 | group('async operations', () { 84 | test('resolves existing template asynchronously', () async { 85 | final source = await root.resolveAsync('simple.liquid'); 86 | expect(source.content, equals('Hello, {{ name }}!')); 87 | expect(source.file?.path, equals('simple.liquid')); 88 | }); 89 | 90 | test('throws exception for non-existent template asynchronously', 91 | () async { 92 | expect(() => root.resolveAsync('non_existent.liquid'), throwsException); 93 | }); 94 | 95 | test('handles delayed template resolution', () async { 96 | final delayedRoot = MockRoot({'delayed.liquid': 'Delayed content'}, 97 | delay: Duration(milliseconds: 100)); 98 | 99 | final stopwatch = Stopwatch()..start(); 100 | await delayedRoot.resolveAsync('delayed.liquid'); 101 | stopwatch.stop(); 102 | 103 | expect(stopwatch.elapsedMilliseconds, greaterThanOrEqualTo(100)); 104 | }); 105 | }); 106 | 107 | group('error handling', () { 108 | test('handles sync resolution errors gracefully', () { 109 | expect(() => root.resolve('missing.liquid'), throwsException); 110 | }); 111 | 112 | test('handles async resolution errors gracefully', () async { 113 | expect(() => root.resolveAsync('missing.liquid'), throwsException); 114 | }); 115 | }); 116 | }); 117 | } 118 | -------------------------------------------------------------------------------- /lib/src/filters/html.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/filter_registry.dart'; 2 | import 'package:liquify/src/filters/module.dart'; 3 | 4 | final Map _escapeMap = { 5 | '&': '&', 6 | '<': '<', 7 | '>': '>', 8 | '"': '"', 9 | "'": ''', 10 | }; 11 | 12 | final Map _unescapeMap = { 13 | '&': '&', 14 | '<': '<', 15 | '>': '>', 16 | '"': '"', 17 | ''': "'", 18 | }; 19 | 20 | String _stringify(dynamic value) => value?.toString() ?? ''; 21 | 22 | /// Escapes HTML special characters in a string. 23 | /// 24 | /// Usage: {{ value | escape }} 25 | /// 26 | /// Example: 27 | /// Input: {{ "

Hello & welcome

" | escape }} 28 | /// Output: <p>Hello & welcome</p> 29 | FilterFunction escape = (dynamic value, List arguments, 30 | Map namedArguments) { 31 | final str = _stringify(value); 32 | return str.replaceAllMapped( 33 | RegExp('[&<>"\']'), 34 | (Match match) => _escapeMap[match.group(0)] ?? '', 35 | ); 36 | }; 37 | 38 | /// Alias for the `escape` filter. 39 | /// 40 | /// Usage: {{ value | xml_escape }} 41 | /// 42 | /// Example: 43 | /// Input: {{ "

Hello & welcome

" | xml_escape }} 44 | /// Output: <p>Hello & welcome</p> 45 | FilterFunction xmlEscape = escape; 46 | 47 | /// Unescapes HTML entities in a string. 48 | /// 49 | /// Usage: {{ value | unescape }} 50 | /// 51 | /// Example: 52 | /// Input: {{ "<p>Hello & welcome</p>" | unescape }} 53 | /// Output:

Hello & welcome

54 | FilterFunction unescape = (dynamic value, List arguments, 55 | Map namedArguments) { 56 | final str = _stringify(value); 57 | return str.replaceAllMapped( 58 | RegExp(r'&(amp|lt|gt|#34|#39);'), 59 | (Match m) => _unescapeMap[m.group(0)] ?? m.group(0)!, 60 | ); 61 | }; 62 | 63 | /// Escapes HTML special characters in a string, but doesn't re-escape entities. 64 | /// 65 | /// Usage: {{ value | escape_once }} 66 | /// 67 | /// Example: 68 | /// Input: {{ "<p>Hello & welcome</p>" | escape_once }} 69 | /// Output: <p>Hello & welcome</p> 70 | FilterFunction escapeOnce = (dynamic value, List arguments, 71 | Map namedArguments) { 72 | final unescaped = unescape(value, arguments, namedArguments); 73 | return escape(unescaped, arguments, namedArguments); 74 | }; 75 | 76 | /// Converts newlines to HTML line breaks. 77 | /// 78 | /// Usage: {{ value | newline_to_br }} 79 | /// 80 | /// Example: 81 | /// Input: {{ "Hello\nWorld" | newline_to_br }} 82 | /// Output: Hello
\nWorld 83 | FilterFunction newlineToBr = (dynamic value, List arguments, 84 | Map namedArguments) { 85 | final str = _stringify(value); 86 | return str.replaceAll(RegExp(r'\r?\n'), '
\n'); 87 | }; 88 | 89 | /// Strips all HTML tags from a string. 90 | /// 91 | /// Usage: {{ value | strip_html }} 92 | /// 93 | /// Example: 94 | /// Input: {{ "

Hello World

" | strip_html }} 95 | /// Output: Hello World 96 | FilterFunction stripHtml = (dynamic value, List arguments, 97 | Map namedArguments) { 98 | final str = _stringify(value); 99 | return str.replaceAll( 100 | RegExp( 101 | r'||<.*?>|'), 102 | '', 103 | ); 104 | }; 105 | 106 | /// Strips all newlines from a string. 107 | /// 108 | /// Usage: {{ value | strip_newlines }} 109 | /// 110 | /// Example: 111 | /// Input: {{ "Hello\nWorld" | strip_newlines }} 112 | /// Output: HelloWorld 113 | FilterFunction stripNewlines = (dynamic value, List arguments, 114 | Map namedArguments) { 115 | final str = _stringify(value); 116 | return str.replaceAll(RegExp(r'\r?\n'), ''); 117 | }; 118 | 119 | /// Map of HTML filter names to their corresponding functions. 120 | class HtmlModule extends Module { 121 | @override 122 | void register() { 123 | filters['escape'] = escape; 124 | filters['xml_escape'] = xmlEscape; 125 | filters['unescape'] = unescape; 126 | filters['escape_once'] = escapeOnce; 127 | filters['newline_to_br'] = newlineToBr; 128 | filters['strip_html'] = stripHtml; 129 | filters['strip_newlines'] = stripNewlines; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/src/tags/if.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/ast.dart'; 2 | import 'package:liquify/src/buffer.dart'; 3 | import 'package:liquify/src/evaluator.dart'; 4 | import 'package:liquify/src/exceptions.dart'; 5 | import 'package:liquify/src/tags/tag.dart'; 6 | import 'package:liquify/src/util.dart'; 7 | 8 | class IfTag extends AbstractTag with AsyncTag { 9 | bool conditionMet = false; 10 | 11 | IfTag(super.content, super.filters); 12 | 13 | void _renderBlockSync( 14 | Evaluator evaluator, Buffer buffer, List body) { 15 | for (final subNode in body) { 16 | if (subNode is Tag && 17 | (subNode.name == 'else' || subNode.name == 'elsif')) { 18 | continue; 19 | } 20 | 21 | try { 22 | if (subNode is Tag) { 23 | evaluator.evaluate(subNode); 24 | } else { 25 | buffer.write(evaluator.evaluate(subNode)); 26 | } 27 | } on BreakException { 28 | throw BreakException(); 29 | } on ContinueException { 30 | throw ContinueException(); 31 | } 32 | } 33 | } 34 | 35 | Future _renderBlockAsync( 36 | Evaluator evaluator, Buffer buffer, List body) async { 37 | for (final subNode in body) { 38 | if (subNode is Tag && 39 | (subNode.name == 'else' || subNode.name == 'elsif')) { 40 | continue; 41 | } 42 | 43 | try { 44 | if (subNode is Tag) { 45 | await evaluator.evaluateAsync(subNode); 46 | } else { 47 | buffer.write(await evaluator.evaluateAsync(subNode)); 48 | } 49 | } on BreakException { 50 | throw BreakException(); 51 | } on ContinueException { 52 | throw ContinueException(); 53 | } 54 | } 55 | } 56 | 57 | @override 58 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 59 | // Get main condition result 60 | conditionMet = isTruthy(evaluator.evaluate(content[0])); 61 | 62 | // Get all else/elsif blocks 63 | final elseBlock = 64 | body.where((n) => n is Tag && n.name == 'else').firstOrNull as Tag?; 65 | final elsifTags = 66 | body.where((n) => n is Tag && (n.name == 'elsif')).cast().toList(); 67 | 68 | // Main if condition 69 | if (conditionMet) { 70 | // Filter out else/elsif nodes from main body evaluation 71 | final mainBody = body 72 | .where((n) => !(n is Tag && (n.name == 'else' || n.name == 'elsif'))) 73 | .toList(); 74 | _renderBlockSync(evaluator, buffer, mainBody); 75 | return; 76 | } 77 | 78 | // Try elsif conditions in order 79 | for (var elsif in elsifTags) { 80 | if (elsif.content.isEmpty) continue; 81 | 82 | final elIfConditionMet = isTruthy(evaluator.evaluate(elsif.content[0])); 83 | if (elIfConditionMet) { 84 | _renderBlockSync(evaluator, buffer, elsif.body); 85 | return; 86 | } 87 | } 88 | 89 | // Fallback to else block 90 | if (elseBlock != null) { 91 | _renderBlockSync(evaluator, buffer, elseBlock.body); 92 | } 93 | } 94 | 95 | @override 96 | Future evaluateWithContextAsync( 97 | Evaluator evaluator, Buffer buffer) async { 98 | // Get main condition result 99 | conditionMet = isTruthy(await evaluator.evaluateAsync(content[0])); 100 | 101 | // Get all else/elsif blocks 102 | final elseBlock = 103 | body.where((n) => n is Tag && n.name == 'else').firstOrNull as Tag?; 104 | final elsifTags = 105 | body.where((n) => n is Tag && (n.name == 'elsif')).cast().toList(); 106 | 107 | // Main if condition 108 | if (conditionMet) { 109 | // Filter out else/elsif nodes from main body evaluation 110 | final mainBody = body 111 | .where((n) => !(n is Tag && (n.name == 'else' || n.name == 'elsif'))) 112 | .toList(); 113 | await _renderBlockAsync(evaluator, buffer, mainBody); 114 | return; 115 | } 116 | 117 | // Try elsif conditions in order 118 | for (var elsif in elsifTags) { 119 | if (elsif.content.isEmpty) continue; 120 | 121 | final elIfConditionMet = 122 | isTruthy(await evaluator.evaluateAsync(elsif.content[0])); 123 | if (elIfConditionMet) { 124 | await _renderBlockAsync(evaluator, buffer, elsif.body); 125 | return; 126 | } 127 | } 128 | 129 | // Fallback to else block 130 | if (elseBlock != null) { 131 | await _renderBlockAsync(evaluator, buffer, elseBlock.body); 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /lib/src/tags/tag.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:liquify/src/ast.dart'; 4 | import 'package:liquify/src/buffer.dart'; 5 | 6 | import '../evaluator.dart'; 7 | 8 | typedef TagCreator = Function(List, List); 9 | 10 | /// Marker mixin for tags that support async operations 11 | mixin AsyncTag { 12 | bool get isAsync => true; 13 | } 14 | 15 | /// Abstract base class for all Liquid tags. 16 | abstract class AbstractTag { 17 | /// The content nodes of the tag. 18 | final List content; 19 | 20 | /// The filters applied to the tag's output. 21 | final List filters; 22 | 23 | /// The body nodes of the tag (for block tags). 24 | List body = []; 25 | 26 | /// Constructs a new BaseTag with the given content, filters, and optional body. 27 | AbstractTag(this.content, this.filters, [this.body = const []]); 28 | 29 | /// Returns a list of all named arguments in the tag's content. 30 | List get namedArgs => 31 | content.whereType().toList(); 32 | 33 | /// Returns a list of all identifiers in the tag's content. 34 | List get args => content.whereType().toList(); 35 | 36 | /// Preprocesses the tag's content. Override this method for custom preprocessing. 37 | void preprocess(Evaluator evaluator) { 38 | // Default implementation does nothing 39 | } 40 | 41 | /// Evaluates the tag's content and returns the result as a string. 42 | dynamic evaluateContent(Evaluator evaluator) { 43 | return content.map((node) => evaluator.evaluate(node)).join(''); 44 | } 45 | 46 | dynamic evaluateContentAsync(Evaluator eval) { 47 | return Future.wait(content.map((node) => eval.evaluateAsync(node))) 48 | .then((results) => results.join('')); 49 | } 50 | 51 | /// Applies the tag's filters to the given value. 52 | dynamic applyFilters(dynamic value, Evaluator evaluator) { 53 | var result = value; 54 | for (final filter in filters) { 55 | final filterFunction = evaluator.context.getFilter(filter.name.name); 56 | if (filterFunction == null) { 57 | throw Exception('Undefined filter: ${filter.name.name}'); 58 | } 59 | final args = 60 | filter.arguments.map((arg) => evaluator.evaluate(arg)).toList(); 61 | result = filterFunction(result, args, {}); 62 | } 63 | return result; 64 | } 65 | 66 | Future applyFiltersAsync(dynamic value, Evaluator evaluator) async { 67 | for (final filter in filters) { 68 | final filterFunction = evaluator.context.getFilter(filter.name.name); 69 | if (filterFunction == null) { 70 | throw Exception('Undefined filter: ${filter.name.name}'); 71 | } 72 | final args = await Future.wait( 73 | filter.arguments.map((arg) => evaluator.evaluateAsync(arg))); 74 | value = filterFunction(value, args, {}); 75 | } 76 | return value; 77 | } 78 | 79 | /// Evaluates the tag with proper scope management 80 | FutureOr evaluateAsync(Evaluator evaluator, Buffer buffer) async { 81 | evaluator.context.pushScope(); 82 | 83 | final innerEvaluator = evaluator.createInnerEvaluator() 84 | ..context.setRoot(evaluator.context.getRoot()); 85 | 86 | var result = await evaluateWithContextAsync(innerEvaluator, buffer); 87 | 88 | // Store the variables from the current scope before popping it 89 | final currentScopeVariables = innerEvaluator.context.all(); 90 | evaluator.context.popScope(); 91 | 92 | // Merge the stored variables back into the previous scope 93 | evaluator.context.merge(currentScopeVariables); 94 | 95 | return result; 96 | } 97 | 98 | dynamic evaluate(Evaluator evaluator, Buffer buffer) { 99 | evaluator.context.pushScope(); 100 | 101 | final innerEvaluator = evaluator.createInnerEvaluator() 102 | ..context.setRoot(evaluator.context.getRoot()); 103 | 104 | var result = evaluateWithContext(innerEvaluator, buffer); 105 | 106 | // Store the variables from the current scope before popping it 107 | final currentScopeVariables = innerEvaluator.context.all(); 108 | evaluator.context.popScope(); 109 | 110 | // Merge the stored variables back into the previous scope 111 | evaluator.context.merge(currentScopeVariables); 112 | 113 | return result; 114 | } 115 | 116 | /// Override this method in subclasses to implement tag behavior 117 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) {} 118 | Future evaluateWithContextAsync( 119 | Evaluator evaluator, Buffer buffer) async {} 120 | } 121 | -------------------------------------------------------------------------------- /lib/src/template.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/parser.dart' as parser; 2 | import 'package:liquify/parser.dart'; 3 | import 'package:liquify/src/fs.dart'; 4 | 5 | class Template { 6 | final String _templateContent; 7 | final Evaluator _evaluator; 8 | 9 | /// Creates a new Template instance from a file. 10 | /// 11 | /// [templateName] is the name or path of the template to be rendered. 12 | /// [root] is the Root object used for resolving templates. 13 | /// [data] is an optional map of variables to be used in the template evaluation. 14 | /// [environment] is an optional custom Environment instance to use. 15 | /// [environmentSetup] is an optional callback to configure the environment with custom filters/tags. 16 | Template.fromFile(String templateName, Root root, 17 | {Map data = const {}, 18 | Environment? environment, 19 | void Function(Environment)? environmentSetup}) 20 | : _templateContent = root.resolve(templateName).content, 21 | _evaluator = Evaluator( 22 | _createEnvironment(data, environment, environmentSetup) 23 | ..setRoot(root)); 24 | 25 | /// Creates a new Template instance from a string. 26 | /// 27 | /// [input] is the string content of the template. 28 | /// [data] is an optional map of variables to be used in the template evaluation. 29 | /// [root] is an optional Root object used for resolving templates. 30 | /// [environment] is an optional custom Environment instance to use. 31 | /// [environmentSetup] is an optional callback to configure the environment with custom filters/tags. 32 | Template.parse( 33 | String input, { 34 | Map data = const {}, 35 | Root? root, 36 | Environment? environment, 37 | void Function(Environment)? environmentSetup, 38 | }) : _templateContent = input, 39 | _evaluator = Evaluator( 40 | _createEnvironment(data, environment, environmentSetup) 41 | ..setRoot(root)); 42 | 43 | /// Renders the template with the current context. 44 | /// 45 | /// Returns the rendered output as a String. 46 | /// 47 | /// [clearBuffer] determines whether to clear the evaluator's buffer after rendering. 48 | /// If set to true (default), the buffer is cleared. If false, the buffer retains its content. 49 | /// Synchronously renders the template and returns the result. 50 | /// 51 | /// If [clearBuffer] is true (default), the internal buffer will be cleared after rendering. 52 | String render({bool clearBuffer = true}) { 53 | final parsed = parser.parseInput(_templateContent); 54 | _evaluator.evaluateNodes(parsed); 55 | final result = _evaluator.buffer.toString(); 56 | if (clearBuffer) { 57 | _evaluator.buffer.clear(); 58 | } 59 | return result; 60 | } 61 | 62 | /// Asynchronously renders the template and returns the result. 63 | /// 64 | /// This method should be used when the template contains async operations 65 | /// like file includes or custom async filters. 66 | /// 67 | /// If [clearBuffer] is true (default), the internal buffer will be cleared after rendering. 68 | Future renderAsync({bool clearBuffer = true}) async { 69 | final parsed = parser.parseInput(_templateContent); 70 | if (clearBuffer) { 71 | _evaluator.buffer.clear(); 72 | } 73 | await _evaluator.evaluateNodesAsync(parsed); 74 | final result = _evaluator.buffer.toString(); 75 | 76 | return result; 77 | } 78 | 79 | /// Updates the template context with new data. 80 | /// 81 | /// [newData] is a map of variables to be merged into the existing context. 82 | void updateContext(Map newData) { 83 | _evaluator.context.merge(newData); 84 | } 85 | 86 | /// Gets the current environment used by this template. 87 | /// 88 | /// This allows access to the environment for registering additional 89 | /// filters or tags after template creation. 90 | Environment get environment => _evaluator.context; 91 | 92 | /// Helper method to create an environment based on the provided parameters. 93 | static Environment _createEnvironment( 94 | Map data, 95 | Environment? environment, 96 | void Function(Environment)? environmentSetup, 97 | ) { 98 | Environment env; 99 | 100 | if (environment != null) { 101 | // Use the provided environment, merge in any data 102 | env = environment.clone(); 103 | env.merge(data); 104 | } else { 105 | // Create a new environment with the data 106 | env = Environment(data); 107 | } 108 | 109 | // Apply any environment setup callback 110 | if (environmentSetup != null) { 111 | environmentSetup(env); 112 | } 113 | 114 | return env; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /test/tags/decrement_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('DecrementTag', () { 19 | group('sync evaluation', () { 20 | test('starts from -1', () async { 21 | await testParser('{% decrement counter %}', (document) { 22 | evaluator.evaluateNodes(document.children); 23 | expect(evaluator.buffer.toString(), '-1'); 24 | }); 25 | }); 26 | 27 | test('decrements sequentially', () async { 28 | await testParser(''' 29 | {% decrement counter %} 30 | {% decrement counter %} 31 | {% decrement counter %} 32 | ''', (document) { 33 | evaluator.evaluateNodes(document.children); 34 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 35 | '-1-2-3'); 36 | }); 37 | }); 38 | 39 | test('is independent from assign', () async { 40 | await testParser(''' 41 | {% assign counter = 42 %} 42 | {% decrement counter %} 43 | {{ counter }} 44 | ''', (document) { 45 | evaluator.evaluateNodes(document.children); 46 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 47 | '-142'); 48 | }); 49 | }); 50 | 51 | test('shares state with increment', () async { 52 | await testParser(''' 53 | {% increment counter %} 54 | {% decrement counter %} 55 | {% increment counter %} 56 | ''', (document) { 57 | evaluator.evaluateNodes(document.children); 58 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 59 | '0-10'); 60 | }); 61 | }); 62 | 63 | test('maintains separate counters', () async { 64 | await testParser(''' 65 | {% decrement counter1 %} 66 | {% decrement counter2 %} 67 | {% decrement counter1 %} 68 | {% decrement counter2 %} 69 | ''', (document) { 70 | evaluator.evaluateNodes(document.children); 71 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 72 | '-1-1-2-2'); 73 | }); 74 | }); 75 | }); 76 | 77 | group('async evaluation', () { 78 | test('starts from -1', () async { 79 | await testParser('{% decrement counter %}', (document) async { 80 | await evaluator.evaluateNodesAsync(document.children); 81 | expect(evaluator.buffer.toString(), '-1'); 82 | }); 83 | }); 84 | 85 | test('decrements sequentially', () async { 86 | await testParser(''' 87 | {% decrement counter %} 88 | {% decrement counter %} 89 | {% decrement counter %} 90 | ''', (document) async { 91 | await evaluator.evaluateNodesAsync(document.children); 92 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 93 | '-1-2-3'); 94 | }); 95 | }); 96 | 97 | test('is independent from assign', () async { 98 | await testParser(''' 99 | {% assign counter = 42 %} 100 | {% decrement counter %} 101 | {{ counter }} 102 | ''', (document) async { 103 | await evaluator.evaluateNodesAsync(document.children); 104 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 105 | '-142'); 106 | }); 107 | }); 108 | 109 | test('shares state with increment', () async { 110 | await testParser(''' 111 | {% increment counter %} 112 | {% decrement counter %} 113 | {% increment counter %} 114 | ''', (document) async { 115 | await evaluator.evaluateNodesAsync(document.children); 116 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 117 | '0-10'); 118 | }); 119 | }); 120 | 121 | test('maintains separate counters', () async { 122 | await testParser(''' 123 | {% decrement counter1 %} 124 | {% decrement counter2 %} 125 | {% decrement counter1 %} 126 | {% decrement counter2 %} 127 | ''', (document) async { 128 | await evaluator.evaluateNodesAsync(document.children); 129 | expect(evaluator.buffer.toString().replaceAll(RegExp(r'\s+'), ''), 130 | '-1-1-2-2'); 131 | }); 132 | }); 133 | }); 134 | }); 135 | } 136 | -------------------------------------------------------------------------------- /lib/src/tags/render.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/tag.dart'; 3 | import 'package:liquify/src/tags/for.dart'; 4 | 5 | class RenderTag extends AbstractTag with AsyncTag { 6 | late String templateName; 7 | late Map variables; 8 | bool hasFor = false; 9 | 10 | RenderTag(super.content, super.filters); 11 | 12 | @override 13 | void preprocess(Evaluator evaluator) { 14 | if (content.isEmpty || content.first is! Literal) { 15 | throw Exception( 16 | 'RenderTag requires a template name as the first argument.'); 17 | } 18 | 19 | templateName = (content.first as Literal).value; 20 | variables = {}; 21 | 22 | for (final arg in namedArgs) { 23 | variables[arg.identifier.name] = evaluator.evaluate(arg.value); 24 | } 25 | 26 | if (args.length > 1) { 27 | final withArg = args[0]; 28 | if (withArg.name == 'with') { 29 | if (args.length < 3) { 30 | throw Exception('RenderTag with "with" requires an object to pass.'); 31 | } 32 | final object = evaluator.evaluate(args[1]); 33 | if (args.length == 4 && args[2].name == 'as') { 34 | variables[(args[3]).name] = object; 35 | } else { 36 | variables.addAll(object as Map); 37 | } 38 | } else if (withArg.name == 'for') { 39 | if (args.length < 3) { 40 | throw Exception( 41 | 'RenderTag with "for" requires an enumerable object.'); 42 | } 43 | hasFor = true; 44 | } 45 | } 46 | } 47 | 48 | void _renderTemplateSync(String template, Evaluator evaluator, Buffer buffer, 49 | Map localVariables) { 50 | final templateNodes = evaluator.resolveAndParseTemplate(template); 51 | var env = Environment(); 52 | final currentRoot = evaluator.context.getRoot(); 53 | 54 | if (currentRoot != null) { 55 | env.setRoot(currentRoot); 56 | } 57 | 58 | final innerEvaluator = Evaluator(env); 59 | innerEvaluator.context.pushScope(); 60 | 61 | for (final entry in localVariables.entries) { 62 | innerEvaluator.context.setVariable(entry.key, entry.value); 63 | } 64 | 65 | innerEvaluator.evaluateNodes(templateNodes); 66 | buffer.write(innerEvaluator.buffer.toString()); 67 | innerEvaluator.context.popScope(); 68 | } 69 | 70 | Future _renderTemplateAsync(String template, Evaluator evaluator, 71 | Buffer buffer, Map localVariables) async { 72 | final templateNodes = 73 | await evaluator.resolveAndParseTemplateAsync(template); 74 | var env = Environment(); 75 | final currentRoot = evaluator.context.getRoot(); 76 | 77 | if (currentRoot != null) { 78 | env.setRoot(currentRoot); 79 | } 80 | 81 | final innerEvaluator = Evaluator(env); 82 | innerEvaluator.context.pushScope(); 83 | 84 | for (final entry in localVariables.entries) { 85 | innerEvaluator.context.setVariable(entry.key, entry.value); 86 | } 87 | 88 | await innerEvaluator.evaluateNodesAsync(templateNodes); 89 | buffer.write(innerEvaluator.buffer.toString()); 90 | innerEvaluator.context.popScope(); 91 | } 92 | 93 | @override 94 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 95 | if (hasFor) { 96 | if (args.length != 4 || args[2].name != 'as') { 97 | throw Exception( 98 | 'RenderTag with "for" requires "as" and a variable name.'); 99 | } 100 | 101 | final enumerable = evaluator.evaluate(args[1]); 102 | for (var i = 0; i < enumerable.length; i++) { 103 | final localVariables = Map.from(variables); 104 | localVariables['forloop'] = 105 | ForLoopObject(index: i, length: enumerable.length).toMap(); 106 | localVariables[args[3].name] = enumerable[i]; 107 | 108 | _renderTemplateSync(templateName, evaluator, buffer, localVariables); 109 | } 110 | } else { 111 | _renderTemplateSync(templateName, evaluator, buffer, variables); 112 | } 113 | } 114 | 115 | @override 116 | Future evaluateWithContextAsync( 117 | Evaluator evaluator, Buffer buffer) async { 118 | if (hasFor) { 119 | if (args.length != 4 || args[2].name != 'as') { 120 | throw Exception( 121 | 'RenderTag with "for" requires "as" and a variable name.'); 122 | } 123 | 124 | final enumerable = await evaluator.evaluateAsync(args[1]); 125 | for (var i = 0; i < enumerable.length; i++) { 126 | final localVariables = Map.from(variables); 127 | localVariables['forloop'] = 128 | ForLoopObject(index: i, length: enumerable.length).toMap(); 129 | localVariables[args[3].name] = enumerable[i]; 130 | 131 | await _renderTemplateAsync( 132 | templateName, evaluator, buffer, localVariables); 133 | } 134 | } else { 135 | await _renderTemplateAsync(templateName, evaluator, buffer, variables); 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /test/tags/if_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | 18 | group('IfTag', () { 19 | group('sync evaluation', () { 20 | test('basic if statement', () async { 21 | await testParser('{% if true %}True{% endif %}', (document) { 22 | evaluator.evaluateNodes(document.children); 23 | expect(evaluator.buffer.toString(), 'True'); 24 | }); 25 | }); 26 | 27 | test('if-else statement', () async { 28 | await testParser('{% if false %}True{% else %}False{% endif %}', 29 | (document) { 30 | evaluator.evaluateNodes(document.children); 31 | expect(evaluator.buffer.toString(), 'False'); 32 | }); 33 | }); 34 | 35 | test('nested if statements', () async { 36 | await testParser( 37 | '{% if true %}{% if false %}Inner False{% else %}Inner True{% endif %}{% endif %}', 38 | (document) { 39 | evaluator.evaluateNodes(document.children); 40 | expect(evaluator.buffer.toString(), 'Inner True'); 41 | }); 42 | }); 43 | 44 | test('if statement with break', () async { 45 | await testParser( 46 | '{% for item in (1..5) %}{% if item == 3 %}{% break %}{% endif %}{{ item }}{% endfor %}', 47 | (document) { 48 | evaluator.evaluateNodes(document.children); 49 | expect(evaluator.buffer.toString(), '12'); 50 | }); 51 | }); 52 | 53 | test('if statement with continue', () async { 54 | await testParser( 55 | '{% for item in (1..5) %}{% if item == 3 %}{% continue %}{% endif %}{{ item }}{% endfor %}', 56 | (document) { 57 | evaluator.evaluateNodes(document.children); 58 | expect(evaluator.buffer.toString(), '1245'); 59 | }); 60 | }); 61 | 62 | test('if with multiple elsifs', () async { 63 | await testParser(''' 64 | {% if false %} 65 | first 66 | {% elsif true %} 67 | second 68 | {% elsif true %} 69 | third 70 | {% else %} 71 | fourth 72 | {% endif %} 73 | ''', (document) { 74 | evaluator.evaluateNodes(document.children); 75 | expect(evaluator.buffer.toString().trim(), 'second'); 76 | }); 77 | }); 78 | }); 79 | 80 | group('async evaluation', () { 81 | test('basic if statement', () async { 82 | await testParser('{% if true %}True{% endif %}', (document) async { 83 | await evaluator.evaluateNodesAsync(document.children); 84 | expect(evaluator.buffer.toString(), 'True'); 85 | }); 86 | }); 87 | 88 | test('if-else statement', () async { 89 | await testParser('{% if false %}True{% else %}False{% endif %}', 90 | (document) async { 91 | await evaluator.evaluateNodesAsync(document.children); 92 | expect(evaluator.buffer.toString(), 'False'); 93 | }); 94 | }); 95 | 96 | test('nested if statements', () async { 97 | await testParser( 98 | '{% if true %}{% if false %}Inner False{% else %}Inner True{% endif %}{% endif %}', 99 | (document) async { 100 | await evaluator.evaluateNodesAsync(document.children); 101 | expect(evaluator.buffer.toString(), 'Inner True'); 102 | }); 103 | }); 104 | 105 | test('if statement with break', () async { 106 | await testParser( 107 | '{% for item in (1..5) %}{% if item == 3 %}{% break %}{% endif %}{{ item }}{% endfor %}', 108 | (document) async { 109 | await evaluator.evaluateNodesAsync(document.children); 110 | expect(evaluator.buffer.toString(), '12'); 111 | }); 112 | }); 113 | 114 | test('if statement with continue', () async { 115 | await testParser( 116 | '{% for item in (1..5) %}{% if item == 3 %}{% continue %}{% endif %}{{ item }}{% endfor %}', 117 | (document) async { 118 | await evaluator.evaluateNodesAsync(document.children); 119 | expect(evaluator.buffer.toString(), '1245'); 120 | }); 121 | }); 122 | 123 | test('if with multiple elsifs', () async { 124 | await testParser(''' 125 | {% if false %} 126 | first 127 | {% elsif true %} 128 | second 129 | {% elsif true %} 130 | third 131 | {% else %} 132 | fourth 133 | {% endif %} 134 | ''', (document) async { 135 | await evaluator.evaluateNodesAsync(document.children); 136 | expect(evaluator.buffer.toString().trim(), 'second'); 137 | }); 138 | }); 139 | }); 140 | }); 141 | } 142 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.3.1 2 | - Fix string literal parsing to correctly handle escaped quotes and characters 3 | - Add regression tests covering render tag and evaluator behaviour with escaped strings 4 | 5 | ## 1.3.0 6 | - Correctly check for elsif tags and not elseif 7 | - tablerow - use seperate buffer for each row being rendered 8 | - refactor parsers for if/case/for to better capture content in else branches etc 9 | 10 | 11 | ## 1.2.0 12 | 13 | ### 🔒 Environment-Scoped Registry 14 | - Add environment-scoped filter and tag registration 15 | - Add strict mode for security sandboxing 16 | - Add `Environment.withStrictMode()` constructor 17 | - Add Template API support for custom environments 18 | - Add environment cloning with local registrations 19 | 20 | ### 🔧 Parser Improvements 21 | - Fix operators to prevent invalid matches with adjacent words 22 | - Enhance comparison, logical, and unary operator parsing 23 | - Improve operator precedence handling 24 | 25 | ### 🏷️ Render Tag Fixes 26 | - Fix variable scope isolation in render tags 27 | - Prevent variable leakage to parent scope 28 | - Add comprehensive scope isolation tests 29 | 30 | ### 📚 Documentation 31 | - Add environment-scoped registry documentation 32 | 33 | 34 | ## 1.1.0 35 | 36 | ### 📚 Documentation 37 | - **Complete documentation overhaul**: Added comprehensive documentation system 38 | - **Examples documentation**: Added 5 detailed example guides covering basic usage, layouts, custom tags, drop objects, and file system integration 39 | - **Tags documentation**: Complete reference for all 21 tags with usage patterns and examples 40 | - **Filters documentation**: Comprehensive documentation for 68+ filters across 7 categories (array, string, math, date, HTML, URL, misc) 41 | - **API documentation**: Structured documentation hub with cross-references and navigation 42 | 43 | ### 🧮 Filters 44 | - **NEW**: Add `parse_json` filter for parsing JSON strings into Dart objects 45 | - **Enhancement**: Math filters now handle null values gracefully (null treated as 0, prevents runtime type cast errors) 46 | - **Enhancement**: Complete expression-based filters (where_exp, find_exp, group_by_exp, etc.) with proper Liquid expression evaluation 47 | - **Enhancement**: Add missing array manipulation filters: compact, concat, push, pop, shift, unshift, reject, sum, sort_natural, group_by, has 48 | - **Fix**: Filter arguments now support member access expressions (e.g., `append: features.size`) 49 | 50 | ### 🏗️ Core Improvements 51 | - **Fix**: Boolean literal parsing in comparison operations (true/false now parsed as literals instead of identifiers) 52 | - **Enhancement**: FileSystemRoot and MapRoot now have extensions and throwOnError support 53 | - **Complete**: 100% filter test coverage with comprehensive regression tests 54 | 55 | ### 🏷️ Tags 56 | - **NEW**: comment 57 | - **NEW**: doc 58 | 59 | ### 🧪 Testing 60 | - Complete test coverage for all implemented filters 61 | - Add regression tests for boolean literal parsing 62 | - Enhanced render tag tests 63 | 64 | ## 1.0.4 65 | - Fix FileSystemRoot to handle null fileSystem argument safely and consistently fallback to LocalFileSystem 66 | - Add tests for FileSystemRoot null fileSystem behavior 67 | 68 | ## 1.0.3 69 | - Fix contains operator 70 | 71 | ## 1.0.2 72 | - allow iterating over key value pairs in for tags 73 | 74 | ## 1.0.1 75 | - Disable internal resolver/analyzer logging 76 | 77 | ## 1.0.0 78 | - Layout tag support 79 | - Async rendering support 80 | 81 | ## 1.0.0-dev.2 82 | - **Template Enhancements:** 83 | - Added layout tag support with title filter. 84 | - Implemented template analyzer and resolver. 85 | 86 | - **Analyzer Improvements:** 87 | - Initial support for a static analyzer. 88 | - Extensive testing and improvements in static analysis. 89 | - Enhanced resolver and analyzer integration. 90 | 91 | - **Filter Enhancements:** 92 | - Enabled dot notation for array filters. 93 | 94 | ## 1.0.0-dev.1 95 | - layout tag support 96 | 97 | ## 0.8.2 98 | - Make sure we register all the missing string filters 99 | 100 | ## 0.8.1 101 | - support elseif tags 102 | 103 | ## 0.8.0 104 | - Start throwing exceptions when parsing fails 105 | 106 | ## 0.7.4 107 | - For tag: make sure iterable is not null before assignment 108 | 109 | ## 0.7.3 110 | - Truthy support for binary operators 111 | 112 | ## 0.7.2 113 | - Array support 114 | 115 | ## 0.7.1 116 | - array support 117 | 118 | ## 0.7.0 119 | - Fix parse failure for single { character 120 | - Better whitespace control handling 121 | - Optional tracing and linting 122 | 123 | ## 0.6.6 124 | 125 | - Allow identifiers with hyphens 126 | 127 | ## 0.6.5 128 | 129 | - Member access fix 130 | 131 | ## 0.6.3 132 | 133 | - Filtered assignment fix 134 | 135 | ## 0.6.2 136 | 137 | - Group filters into modules 138 | 139 | ## 0.6.0 140 | 141 | - Empty type 142 | - MapRoot Root implementation 143 | 144 | ## 0.5.0 145 | 146 | - Filesystem lookup 147 | - Render tag 148 | 149 | ## 0.4.0 150 | 151 | - Drop support 152 | 153 | ## 0.3.0 154 | 155 | - Support floating point numbers 156 | - Support negative numbers 157 | 158 | ## 0.2.0 159 | 160 | - Add built in filters 161 | ## 0.1.0 162 | 163 | - Initial version. 164 | -------------------------------------------------------------------------------- /lib/src/tags/layout.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:math' show Random; 3 | 4 | import 'package:liquify/parser.dart'; 5 | import 'package:liquify/src/analyzer/block_info.dart'; 6 | import 'package:liquify/src/analyzer/resolver.dart'; 7 | import 'package:liquify/src/util.dart'; 8 | 9 | import '../analyzer/template_analyzer.dart'; 10 | 11 | class LayoutTag extends AbstractTag with CustomTagParser, AsyncTag { 12 | final Logger _logger = Logger('LayoutTag'); 13 | late String layoutName; 14 | 15 | LayoutTag(super.content, super.filters); 16 | 17 | @override 18 | dynamic evaluateWithContext(Evaluator evaluator, Buffer buffer) { 19 | _logger.info('Starting layout evaluation'); 20 | if (content.isEmpty) { 21 | throw Exception('LayoutTag requires a name as first argument'); 22 | } 23 | if (content.first is Literal) { 24 | layoutName = (content.first as Literal).value as String; 25 | } else if (content.first is Identifier) { 26 | layoutName = (content.first as Identifier).name; 27 | } 28 | 29 | layoutName = (evaluator.evaluate(content.first)); 30 | layoutName = evaluator.tmpResult(parseInput(layoutName)); 31 | 32 | return buildLayout(evaluator, layoutName.trim(), false); 33 | } 34 | 35 | @override 36 | Future evaluateWithContextAsync( 37 | Evaluator evaluator, Buffer buffer) async { 38 | _logger.info('Starting layout evaluation'); 39 | if (content.first is Literal) { 40 | layoutName = (content.first as Literal).value as String; 41 | } else if (content.first is Identifier) { 42 | layoutName = (content.first as Identifier).name; 43 | } 44 | 45 | layoutName = await evaluator.tmpResultAsync(parseInput(layoutName)); 46 | return await buildLayout(evaluator, layoutName.trim(), true); 47 | } 48 | 49 | @override 50 | Parser parser() { 51 | return ((tagStart() & 52 | string('layout').trim() & 53 | ref0(identifier).or(ref0(stringLiteral)).trim() & 54 | ref0(namedArgument) 55 | .star() 56 | .starSeparated(char(',') | whitespace()) 57 | .trim() & 58 | tagEnd()) & 59 | ref0(element).star()) 60 | .map((values) { 61 | final arguments = [values[2] as ASTNode]; 62 | final elements = values[3].elements as List; 63 | for (var i = 0; i < elements.length; i++) { 64 | if (elements[i] is List) { 65 | final list = elements[i] as List; 66 | for (var j = 0; j < list.length; j++) { 67 | final arg = list[j] as NamedArgument; 68 | arguments.add(arg); 69 | } 70 | continue; 71 | } 72 | } 73 | 74 | final tag = Tag('layout', arguments, body: values[5].cast()); 75 | return tag; 76 | }); 77 | } 78 | 79 | FutureOr buildLayout(Evaluator evaluator, String layoutName, 80 | [bool isAsync = false]) async { 81 | final layoutEvaluator = 82 | evaluator.createInnerEvaluatorWithBuffer(evaluator.buffer); 83 | layoutEvaluator.context.setRoot(evaluator.context.getRoot()); 84 | layoutEvaluator.context.merge(evaluator.context.all()); 85 | 86 | // Process variables from layout tag arguments 87 | for (final arg in content.whereType()) { 88 | final value = evaluator.evaluate(arg.value); 89 | _logger.info('Setting variable ${arg.identifier.name} = $value'); 90 | layoutEvaluator.context.setVariable(arg.identifier.name, value); 91 | } 92 | // Create a template analyzer with the root from the context 93 | final analyzer = TemplateAnalyzer(evaluator.context.getRoot()); 94 | 95 | // First analyze the layout template 96 | final analysis = analyzer.analyzeTemplate(layoutName).last; 97 | final layoutStructure = analysis.structures[layoutName]; 98 | 99 | if (layoutStructure == null) { 100 | throw Exception('Failed to analyze layout template: $layoutName'); 101 | } 102 | 103 | final blocks = 104 | body.whereType().where((tag) => tag.name == 'block').map((tag) { 105 | String source = "templ${Random().nextInt(1000)}.liquid"; 106 | String name = ''; 107 | if (tag.content.isNotEmpty && tag.content.first is Identifier) { 108 | name = (tag.content.first as Identifier).name; 109 | } else if (tag.content.isNotEmpty && tag.content.first is Literal) { 110 | name = (tag.content.first as Literal).value as String; 111 | } 112 | return BlockInfo( 113 | name: name, 114 | source: source, 115 | content: tag.body, 116 | isOverride: true, 117 | nestedBlocks: {}, 118 | hasSuperCall: false); 119 | }).fold>({}, (map, block) { 120 | map[block.name] = block; 121 | return map; 122 | }); 123 | 124 | final mergedAst = 125 | buildCompleteMergedAst(layoutStructure, overrides: blocks); 126 | 127 | // Evaluate the merged AST 128 | _logger.info('Evaluating merged AST'); 129 | for (final node in mergedAst) { 130 | dynamic result; 131 | if (isAsync) { 132 | result = await layoutEvaluator.evaluateAsync(node); 133 | } else { 134 | result = layoutEvaluator.evaluate(node); 135 | } 136 | if (result != null) { 137 | evaluator.buffer.write(result); 138 | } 139 | } 140 | 141 | _logger.info('Layout evaluation complete'); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /test/tags/case_tag_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:liquify/src/context.dart'; 2 | import 'package:liquify/src/evaluator.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | import '../shared.dart'; 6 | 7 | void main() { 8 | late Evaluator evaluator; 9 | 10 | setUp(() { 11 | evaluator = Evaluator(Environment()); 12 | }); 13 | 14 | tearDown(() { 15 | evaluator.context.clear(); 16 | }); 17 | group('Case/when tag', () { 18 | group('sync evaluation', () { 19 | test('case tag with single match', () async { 20 | await testParser( 21 | '{% assign handle = "cake" %}' 22 | '{% case handle %}' 23 | '{% when "cake" %}' 24 | 'This is a cake' 25 | '{% when "cookie" %}' 26 | 'This is a cookie' 27 | '{% else %}' 28 | 'This is not a cake nor a cookie' 29 | '{% endcase %}', (document) { 30 | evaluator.evaluateNodes(document.children); 31 | expect(evaluator.buffer.toString().trim(), 'This is a cake'); 32 | }); 33 | }); 34 | 35 | test('case tag with multiple values in when', () async { 36 | await testParser( 37 | '{% assign handle = "biscuit" %}' 38 | '{% case handle %}' 39 | '{% when "cake" %}' 40 | 'This is a cake' 41 | '{% when "cookie", "biscuit" %}' 42 | 'This is a cookie or biscuit' 43 | '{% else %}' 44 | 'This is something else' 45 | '{% endcase %}', (document) { 46 | evaluator.evaluateNodes(document.children); 47 | expect(evaluator.buffer.toString().trim(), 48 | 'This is a cookie or biscuit'); 49 | }); 50 | }); 51 | 52 | test('case tag with else condition', () async { 53 | await testParser( 54 | '{% assign handle = "pie" %}' 55 | '{% case handle %}' 56 | '{% when "cake" %}' 57 | 'This is a cake' 58 | '{% when "cookie" %}' 59 | 'This is a cookie' 60 | '{% else %}' 61 | 'This is neither a cake nor a cookie' 62 | '{% endcase %}', (document) { 63 | evaluator.evaluateNodes(document.children); 64 | expect(evaluator.buffer.toString().trim(), 65 | 'This is neither a cake nor a cookie'); 66 | }); 67 | }); 68 | 69 | test('case tag with no matching condition and no else', () async { 70 | await testParser( 71 | '{% assign handle = "pie" %}' 72 | '{% case handle %}' 73 | '{% when "cake" %}' 74 | 'This is a cake' 75 | '{% when "cookie" %}' 76 | 'This is a cookie' 77 | '{% endcase %}', (document) { 78 | evaluator.evaluateNodes(document.children); 79 | expect(evaluator.buffer.toString(), ''); 80 | }); 81 | }); 82 | }); 83 | 84 | group('async evaluation', () { 85 | test('case tag with single match', () async { 86 | await testParser( 87 | '{% assign handle = "cake" %}' 88 | '{% case handle %}' 89 | '{% when "cake" %}' 90 | 'This is a cake' 91 | '{% when "cookie" %}' 92 | 'This is a cookie' 93 | '{% else %}' 94 | 'This is not a cake nor a cookie' 95 | '{% endcase %}', (document) async { 96 | await evaluator.evaluateNodesAsync(document.children); 97 | expect(evaluator.buffer.toString().trim(), 'This is a cake'); 98 | }); 99 | }); 100 | 101 | test('case tag with multiple values in when', () async { 102 | await testParser( 103 | '{% assign handle = "biscuit" %}' 104 | '{% case handle %}' 105 | '{% when "cake" %}' 106 | 'This is a cake' 107 | '{% when "cookie", "biscuit" %}' 108 | 'This is a cookie or biscuit' 109 | '{% else %}' 110 | 'This is something else' 111 | '{% endcase %}', (document) async { 112 | await evaluator.evaluateNodesAsync(document.children); 113 | expect(evaluator.buffer.toString().trim(), 114 | 'This is a cookie or biscuit'); 115 | }); 116 | }); 117 | 118 | test('case tag with else condition', () async { 119 | await testParser( 120 | '{% assign handle = "pie" %}' 121 | '{% case handle %}' 122 | '{% when "cake" %}' 123 | 'This is a cake' 124 | '{% when "cookie" %}' 125 | 'This is a cookie' 126 | '{% else %}' 127 | 'This is neither a cake nor a cookie' 128 | '{% endcase %}', (document) async { 129 | await evaluator.evaluateNodesAsync(document.children); 130 | expect(evaluator.buffer.toString().trim(), 131 | 'This is neither a cake nor a cookie'); 132 | }); 133 | }); 134 | 135 | test('case tag with no matching condition and no else', () async { 136 | await testParser( 137 | '{% assign handle = "pie" %}' 138 | '{% case handle %}' 139 | '{% when "cake" %}' 140 | 'This is a cake' 141 | '{% when "cookie" %}' 142 | 'This is a cookie' 143 | '{% endcase %}', (document) async { 144 | await evaluator.evaluateNodesAsync(document.children); 145 | expect(evaluator.buffer.toString(), ''); 146 | }); 147 | }); 148 | }); 149 | }); 150 | } 151 | --------------------------------------------------------------------------------