Repository: Shopify/liquid Branch: main Commit: dd37353cca46 Files: 204 Total size: 720.9 KB Directory structure: gitextract_1on2h7q1/ ├── .github/ │ ├── dependabot.yaml │ └── workflows/ │ ├── cla.yml │ └── liquid.yml ├── .gitignore ├── .rubocop.yml ├── .rubocop_todo.yml ├── .ruby-version ├── CONTRIBUTING.md ├── Gemfile ├── History.md ├── LICENSE ├── README.md ├── Rakefile ├── bin/ │ └── render ├── example/ │ └── server/ │ ├── example_servlet.rb │ ├── liquid_servlet.rb │ ├── server.rb │ └── templates/ │ ├── index.liquid │ └── products.liquid ├── lib/ │ ├── liquid/ │ │ ├── block.rb │ │ ├── block_body.rb │ │ ├── condition.rb │ │ ├── const.rb │ │ ├── context.rb │ │ ├── deprecations.rb │ │ ├── document.rb │ │ ├── drop.rb │ │ ├── environment.rb │ │ ├── errors.rb │ │ ├── expression.rb │ │ ├── extensions.rb │ │ ├── file_system.rb │ │ ├── forloop_drop.rb │ │ ├── i18n.rb │ │ ├── interrupts.rb │ │ ├── lexer.rb │ │ ├── locales/ │ │ │ └── en.yml │ │ ├── parse_context.rb │ │ ├── parse_tree_visitor.rb │ │ ├── parser.rb │ │ ├── parser_switching.rb │ │ ├── partial_cache.rb │ │ ├── profiler/ │ │ │ └── hooks.rb │ │ ├── profiler.rb │ │ ├── range_lookup.rb │ │ ├── registers.rb │ │ ├── resource_limits.rb │ │ ├── standardfilters.rb │ │ ├── strainer_template.rb │ │ ├── tablerowloop_drop.rb │ │ ├── tag/ │ │ │ ├── disableable.rb │ │ │ └── disabler.rb │ │ ├── tag.rb │ │ ├── tags/ │ │ │ ├── assign.rb │ │ │ ├── break.rb │ │ │ ├── capture.rb │ │ │ ├── case.rb │ │ │ ├── comment.rb │ │ │ ├── continue.rb │ │ │ ├── cycle.rb │ │ │ ├── decrement.rb │ │ │ ├── doc.rb │ │ │ ├── echo.rb │ │ │ ├── for.rb │ │ │ ├── if.rb │ │ │ ├── ifchanged.rb │ │ │ ├── include.rb │ │ │ ├── increment.rb │ │ │ ├── inline_comment.rb │ │ │ ├── raw.rb │ │ │ ├── render.rb │ │ │ ├── table_row.rb │ │ │ └── unless.rb │ │ ├── tags.rb │ │ ├── template.rb │ │ ├── template_factory.rb │ │ ├── tokenizer.rb │ │ ├── usage.rb │ │ ├── utils.rb │ │ ├── variable.rb │ │ ├── variable_lookup.rb │ │ └── version.rb │ └── liquid.rb ├── liquid.gemspec ├── performance/ │ ├── benchmark.rb │ ├── memory_profile.rb │ ├── profile.rb │ ├── shopify/ │ │ ├── comment_form.rb │ │ ├── database.rb │ │ ├── json_filter.rb │ │ ├── liquid.rb │ │ ├── money_filter.rb │ │ ├── paginate.rb │ │ ├── shop_filter.rb │ │ ├── tag_filter.rb │ │ ├── vision.database.yml │ │ └── weight_filter.rb │ ├── tests/ │ │ ├── dropify/ │ │ │ ├── article.liquid │ │ │ ├── blog.liquid │ │ │ ├── cart.liquid │ │ │ ├── collection.liquid │ │ │ ├── index.liquid │ │ │ ├── page.liquid │ │ │ ├── product.liquid │ │ │ └── theme.liquid │ │ ├── ripen/ │ │ │ ├── article.liquid │ │ │ ├── blog.liquid │ │ │ ├── cart.liquid │ │ │ ├── collection.liquid │ │ │ ├── index.liquid │ │ │ ├── page.liquid │ │ │ ├── product.liquid │ │ │ └── theme.liquid │ │ ├── tribble/ │ │ │ ├── 404.liquid │ │ │ ├── article.liquid │ │ │ ├── blog.liquid │ │ │ ├── cart.liquid │ │ │ ├── collection.liquid │ │ │ ├── index.liquid │ │ │ ├── page.liquid │ │ │ ├── product.liquid │ │ │ ├── search.liquid │ │ │ └── theme.liquid │ │ └── vogue/ │ │ ├── article.liquid │ │ ├── blog.liquid │ │ ├── cart.liquid │ │ ├── collection.liquid │ │ ├── index.liquid │ │ ├── page.liquid │ │ ├── product.liquid │ │ └── theme.liquid │ ├── theme_runner.rb │ └── unit/ │ ├── expression_benchmark.rb │ └── lexer_benchmark.rb ├── spec/ │ ├── ruby_liquid.rb │ ├── ruby_liquid_lax.rb │ ├── ruby_liquid_with_active_support.rb │ └── ruby_liquid_yjit.rb └── test/ ├── fixtures/ │ └── en_locale.yml ├── integration/ │ ├── assign_test.rb │ ├── blank_test.rb │ ├── block_test.rb │ ├── capture_test.rb │ ├── context_test.rb │ ├── document_test.rb │ ├── drop_test.rb │ ├── error_handling_test.rb │ ├── expression_test.rb │ ├── filter_kwarg_test.rb │ ├── filter_test.rb │ ├── hash_ordering_test.rb │ ├── hash_rendering_test.rb │ ├── output_test.rb │ ├── parsing_quirks_test.rb │ ├── profiler_test.rb │ ├── security_test.rb │ ├── standard_filter_test.rb │ ├── tag/ │ │ └── disableable_test.rb │ ├── tag_test.rb │ ├── tags/ │ │ ├── break_tag_test.rb │ │ ├── continue_tag_test.rb │ │ ├── cycle_tag_test.rb │ │ ├── echo_test.rb │ │ ├── for_tag_test.rb │ │ ├── if_else_tag_test.rb │ │ ├── include_tag_test.rb │ │ ├── increment_tag_test.rb │ │ ├── inline_comment_test.rb │ │ ├── liquid_tag_test.rb │ │ ├── raw_tag_test.rb │ │ ├── render_tag_test.rb │ │ ├── standard_tag_test.rb │ │ ├── statements_test.rb │ │ ├── table_row_test.rb │ │ └── unless_else_tag_test.rb │ ├── template_test.rb │ ├── trim_mode_test.rb │ └── variable_test.rb ├── test_helper.rb └── unit/ ├── block_unit_test.rb ├── condition_unit_test.rb ├── environment_filter_test.rb ├── environment_test.rb ├── file_system_unit_test.rb ├── i18n_unit_test.rb ├── lexer_unit_test.rb ├── parse_context_unit_test.rb ├── parse_tree_visitor_test.rb ├── parser_unit_test.rb ├── partial_cache_unit_test.rb ├── regexp_unit_test.rb ├── registers_unit_test.rb ├── resource_limits_unit_test.rb ├── strainer_template_unit_test.rb ├── tag_unit_test.rb ├── tags/ │ ├── case_tag_unit_test.rb │ ├── comment_tag_unit_test.rb │ ├── doc_tag_unit_test.rb │ ├── for_tag_unit_test.rb │ └── if_tag_unit_test.rb ├── template_factory_unit_test.rb ├── template_unit_test.rb ├── tokenizer_unit_test.rb └── variable_unit_test.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yaml ================================================ version: 2 updates: - package-ecosystem: github-actions directory: "/" schedule: interval: weekly ================================================ FILE: .github/workflows/cla.yml ================================================ name: Contributor License Agreement (CLA) on: pull_request_target: types: [opened, synchronize] issue_comment: types: [created] jobs: cla: runs-on: ubuntu-latest if: | (github.event.issue.pull_request && !github.event.issue.pull_request.merged_at && contains(github.event.comment.body, 'signed') ) || (github.event.pull_request && !github.event.pull_request.merged) steps: - uses: Shopify/shopify-cla-action@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} cla-token: ${{ secrets.CLA_TOKEN }} ================================================ FILE: .github/workflows/liquid.yml ================================================ name: Liquid on: [push] env: BUNDLE_JOBS: 4 BUNDLE_RETRY: 3 jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: entry: - { ruby: 3.3, allowed-failure: false } # minimum supported - { ruby: 3.4, allowed-failure: false, rubyopt: "--yjit" } - { ruby: 4.0, allowed-failure: false } # latest stable - { ruby: 4.0, allowed-failure: false, rubyopt: "--enable-frozen-string-literal", } - { ruby: 4.0, allowed-failure: false, rubyopt: "--yjit" } - { ruby: 4.0, allowed-failure: false, rubyopt: "--zjit" } # Head can have failures due to being in development - { ruby: head, allowed-failure: true } - { ruby: head, allowed-failure: true, rubyopt: "--enable-frozen-string-literal", } - { ruby: head, allowed-failure: true, rubyopt: "--yjit" } - { ruby: head, allowed-failure: true, rubyopt: "--zjit" } name: Test Ruby ${{ matrix.entry.ruby }} ${{ matrix.entry.rubyopt }} --${{ matrix.entry.allowed-failure && 'allowed-failure' || 'strict' }} steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: ruby-version: ${{ matrix.entry.ruby }} bundler-cache: true bundler: latest - run: bundle exec rake continue-on-error: ${{ matrix.entry.allowed-failure }} env: RUBYOPT: ${{ matrix.entry.rubyopt }} spec: runs-on: ubuntu-latest env: BUNDLE_WITH: spec steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: bundler-cache: true bundler: latest - name: Run liquid-spec for all adapters run: | for adapter in spec/*.rb; do echo "=== Running $adapter ===" bundle exec liquid-spec run "$adapter" --no-max-failures done memory_profile: runs-on: ubuntu-latest steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 - uses: ruby/setup-ruby@319994f95fa847cf3fb3cd3dbe89f6dcde9f178f # v1.295.0 with: bundler-cache: true - run: bundle exec rake memory_profile:run ================================================ FILE: .gitignore ================================================ *~ *.gem *.swp pkg *.rbc .rvmrc .bundle .byebug_history Gemfile.lock ================================================ FILE: .rubocop.yml ================================================ inherit_gem: rubocop-shopify: rubocop.yml inherit_from: - .rubocop_todo.yml require: rubocop-performance Performance: Enabled: true AllCops: NewCops: disable SuggestExtensions: false Exclude: - 'vendor/bundle/**/*' Naming/MethodName: Exclude: - 'example/server/liquid_servlet.rb' Style/ClassMethodsDefinitions: Enabled: false # liquid filter calls were being mistaken to be calls on arrays Style/ConcatArrayLiterals: Exclude: - 'test/integration/standard_filter_test.rb' ================================================ FILE: .rubocop_todo.yml ================================================ # This configuration was generated by # `rubocop --auto-gen-config` # on 2022-05-18 19:25:47 UTC using RuboCop version 1.29.1. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. # Offense count: 1 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: TreatCommentsAsGroupSeparators, ConsiderPunctuation, Include. # Include: **/*.gemspec Gemspec/OrderedDependencies: Exclude: - 'liquid.gemspec' # Offense count: 6 # This cop supports safe auto-correction (--auto-correct). Layout/ClosingHeredocIndentation: Exclude: - 'test/integration/tags/for_tag_test.rb' # Offense count: 34 # This cop supports safe auto-correction (--auto-correct). Layout/EmptyLineAfterGuardClause: Exclude: - 'lib/liquid/block.rb' - 'lib/liquid/block_body.rb' - 'lib/liquid/context.rb' - 'lib/liquid/drop.rb' - 'lib/liquid/lexer.rb' - 'lib/liquid/parser.rb' - 'lib/liquid/profiler/hooks.rb' - 'lib/liquid/standardfilters.rb' - 'lib/liquid/tags/for.rb' - 'lib/liquid/tags/if.rb' - 'lib/liquid/utils.rb' - 'lib/liquid/variable.rb' - 'lib/liquid/variable_lookup.rb' - 'performance/shopify/money_filter.rb' - 'performance/shopify/paginate.rb' # Offense count: 8 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: AllowAliasSyntax, AllowedMethods. # AllowedMethods: alias_method, public, protected, private Layout/EmptyLinesAroundAttributeAccessor: Exclude: - 'lib/liquid/template.rb' - 'test/integration/filter_test.rb' - 'test/integration/tags/include_tag_test.rb' - 'test/unit/strainer_template_unit_test.rb' # Offense count: 17 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: aligned, indented Layout/LineEndStringConcatenationIndentation: Exclude: - 'test/integration/tags/for_tag_test.rb' - 'test/integration/tags/increment_tag_test.rb' # Offense count: 1 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: EnforcedStyle, IndentationWidth. # SupportedStyles: aligned, indented Layout/MultilineOperationIndentation: Exclude: - 'lib/liquid/expression.rb' # Offense count: 9 Lint/MissingSuper: Exclude: - 'lib/liquid/forloop_drop.rb' - 'lib/liquid/tablerowloop_drop.rb' - 'test/integration/assign_test.rb' - 'test/integration/context_test.rb' - 'test/integration/filter_test.rb' - 'test/integration/standard_filter_test.rb' - 'test/integration/tags/for_tag_test.rb' - 'test/integration/tags/table_row_test.rb' # Offense count: 44 Naming/ConstantName: Exclude: - 'lib/liquid.rb' - 'lib/liquid/block_body.rb' - 'lib/liquid/tags/assign.rb' - 'lib/liquid/tags/capture.rb' - 'lib/liquid/tags/case.rb' - 'lib/liquid/tags/cycle.rb' - 'lib/liquid/tags/for.rb' - 'lib/liquid/tags/if.rb' - 'lib/liquid/tags/raw.rb' - 'lib/liquid/tags/table_row.rb' - 'lib/liquid/variable.rb' - 'performance/shopify/comment_form.rb' - 'performance/shopify/paginate.rb' - 'test/integration/tags/include_tag_test.rb' # Offense count: 9 # Configuration parameters: CheckIdentifiers, CheckConstants, CheckVariables, CheckStrings, CheckSymbols, CheckComments, CheckFilepaths, FlaggedTerms. Naming/InclusiveLanguage: Exclude: - 'lib/liquid/drop.rb' - 'lib/liquid/parse_context.rb' - 'test/integration/drop_test.rb' - 'test/integration/tags/if_else_tag_test.rb' # Offense count: 2 Style/ClassVars: Exclude: - 'lib/liquid/condition.rb' # Offense count: 3 # This cop supports safe auto-correction (--auto-correct). Style/ExplicitBlockArgument: Exclude: - 'test/integration/context_test.rb' - 'test/integration/tag/disableable_test.rb' - 'test/integration/tags/for_tag_test.rb' # Offense count: 2982 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: EnforcedStyle, ConsistentQuotesInMultiline. # SupportedStyles: single_quotes, double_quotes Style/StringLiterals: Enabled: false # Offense count: 20 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: EnforcedStyle. # SupportedStyles: single_quotes, double_quotes Style/StringLiteralsInInterpolation: Exclude: - 'lib/liquid/condition.rb' - 'lib/liquid/strainer_template.rb' - 'lib/liquid/tag/disableable.rb' - 'performance/shopify/shop_filter.rb' - 'performance/shopify/tag_filter.rb' # Offense count: 6 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: EnforcedStyleForMultiline. # SupportedStylesForMultiline: comma, consistent_comma, no_comma Style/TrailingCommaInArrayLiteral: Exclude: - 'example/server/example_servlet.rb' - 'lib/liquid/condition.rb' - 'test/integration/context_test.rb' - 'test/integration/standard_filter_test.rb' - 'test/unit/parse_tree_visitor_test.rb' # Offense count: 1 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: EnforcedStyleForMultiline. # SupportedStylesForMultiline: comma, consistent_comma, no_comma Style/TrailingCommaInHashLiteral: Exclude: - 'lib/liquid/expression.rb' # Offense count: 19 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: EnforcedStyle, MinSize, WordRegex. # SupportedStyles: percent, brackets Style/WordArray: Exclude: - 'lib/liquid/tags/if.rb' - 'liquid.gemspec' - 'test/integration/assign_test.rb' - 'test/integration/context_test.rb' - 'test/integration/drop_test.rb' - 'test/integration/standard_filter_test.rb' # Offense count: 117 # This cop supports safe auto-correction (--auto-correct). # Configuration parameters: AllowHeredoc, AllowURI, URISchemes, AllowCopDirectives, AllowedPatterns. # URISchemes: http, https Layout/LineLength: Max: 260 Naming/PredicatePrefix: Enabled: false # Offense count: 1 # This is intentional - early return from begin/rescue in assignment context Lint/NoReturnInBeginEndBlocks: Exclude: - 'lib/liquid/standardfilters.rb' ================================================ FILE: .ruby-version ================================================ 3.4.1 ================================================ FILE: CONTRIBUTING.md ================================================ # How to contribute ## Things we will merge * Bugfixes * Performance improvements * Features that are likely to be useful to the majority of Liquid users * Documentation updates that are concise and likely to be useful to the majority of Liquid users ## Things we won't merge * Code that introduces considerable performance degrations * Code that touches performance-critical parts of Liquid and comes without benchmarks * Features that are not important for most people (we want to keep the core Liquid code small and tidy) * Features that can easily be implemented on top of Liquid (for example as a custom filter or custom filesystem) * Code that does not include tests * Code that breaks existing tests * Documentation changes that are verbose, incorrect or not important to most people (we want to keep it simple and easy to understand) ## Workflow * [Sign the CLA](https://cla.shopify.com/) if you haven't already * Fork the Liquid repository * Create a new branch in your fork * For updating [Liquid documentation](https://shopify.github.io/liquid/), create it from `gh-pages` branch. (You can skip tests.) * If it makes sense, add tests for your code and/or run a performance benchmark * Make sure all tests pass (`bundle exec rake`) * Create a pull request ## Releasing * Bump the version in `lib/liquid/version.rb` * Update the `History.md` file * Open a PR like [this one](https://github.com/Shopify/liquid/pull/1894) and merge it to `main` * Create a new release using the [GitHub UI](https://github.com/Shopify/liquid/releases/new) ================================================ FILE: Gemfile ================================================ # frozen_string_literal: true source 'https://rubygems.org' git_source(:github) do |repo_name| "https://github.com/#{repo_name}.git" end gemspec gem "base64" group :benchmark, :test do gem 'benchmark-ips' gem 'memory_profiler' gem 'terminal-table' gem "lru_redux" install_if -> { RUBY_PLATFORM !~ /mingw|mswin|java/ && RUBY_ENGINE != 'truffleruby' } do gem 'stackprof' end end group :development do gem "webrick" end group :test do gem 'benchmark' gem 'rubocop', '~> 1.82.0' gem 'rubocop-shopify', '~> 2.18.0', require: false gem 'rubocop-performance', require: false end group :spec do gem 'liquid-spec', github: 'Shopify/liquid-spec', branch: 'main' gem 'activesupport', require: false end ================================================ FILE: History.md ================================================ # Liquid Change Log ## 5.11.0 * Revert the Inline Snippets tag (#2001), treat its inclusion in the latest Liquid release as a bug, and allow for feedback on RFC#1916 to better support Liquid developers [Guilherme Carreiro] * Rename the `:rigid` error mode to `:strict2` and display a warning when users attempt to use the `:rigid` mode [Guilherme Carreiro] ## 5.10.0 * Introduce support for Inline Snippets [Julia Boutin] ## 5.9.0 * Introduce `:rigid` error mode for stricter, safer parsing of all tags [CP Clermont, Guilherme Carreiro] ## 5.8.7 * Expose body content in the `Doc` tag [James Meng] ## 5.8.1 * Fix `{% doc %}` tag to be visitable [Guilherme Carreiro] ## 5.8.0 * Introduce the new `{% doc %}` tag [Guilherme Carreiro] ## 5.7.3 * Raise Liquid::SyntaxError when parsing invalidly encoded strings [Chris AtLee] ## 5.7.2 2025-01-31 * Fix array filters to not support nested properties [Guilherme Carreiro] ## 5.7.1 2025-01-24 * Fix the `find` and `find_index`filters to return `nil` when filtering empty arrays [Guilherme Carreiro] * Fix the `has` filter to return `false` when filtering empty arrays [Guilherme Carreiro] ## 5.7.0 2025-01-16 ### Features * Add `find`, `find_index`, `has`, and `reject` filters to arrays [Guilherme Carreiro] * Compatibility with Ruby 3.4 [Ian Ker-Seymer] ## 5.6.4 2025-01-14 ### Fixes * Add a default `string_scanner` to avoid errors with `Liquid::VariableLookup.parse("foo.bar")` [Ian Ker-Seymer] ## 5.6.3 2025-01-13 * Remove `lru_redux` dependency [Michael Go] ## 5.6.2 2025-01-13 ### Fixes * Preserve the old behavior of requiring floats to start with a digit [Michael Go] ## 5.6.1 2025-01-13 ### Performance improvements * Faster Expression parser / Tokenizer with StringScanner [Michael Go] ## 5.6.0 2024-12-19 ### Architectural changes * Added new `Environment` class to manage configuration and state that was previously stored in `Template` [Ian Ker-Seymer] * Moved tag registration from `Template` to `Environment` [Ian Ker-Seymer] * Removed `StrainerFactory` in favor of `Environment`-based strainer creation [Ian Ker-Seymer] * Consolidated standard tags into a new `Tags` module with `STANDARD_TAGS` constant [Ian Ker-Seymer] ### Performance improvements * Optimized `Lexer` with a new `Lexer2` implementation using jump tables for faster tokenization, requires Ruby 3.4 [Ian Ker-Seymer] * Improved variable rendering with specialized handling for different types [Michael Go] * Reduced array allocations by using frozen empty constants [Michael Go] ### API changes * Deprecated several `Template` class methods in favor of `Environment` methods [Ian Ker-Seymer] * Added deprecation warnings system [Ian Ker-Seymer] * Changed how filters and tags are registered to use Environment [Ian Ker-Seymer] ### Fixes * Fixed table row handling of break interrupts [Alex Coco] * Improved variable output handling for arrays [Ian Ker-Seymer] * Fix Tokenizer to handle null source value (#1873) [Bahar Pourazar] ## 5.5.0 2024-03-21 Please reference the GitHub release for more information. ## 5.4.0 2022-07-29 ### Breaking Changes * Drop support for end-of-life Ruby versions (2.5 and 2.6) (#1578) [Andy Waite] ### Features * Allow `#` to be used as an inline comment tag (#1498) [CP Clermont] ### Fixes * `PartialCache` now shares snippet cache with subcontexts by default (#1553) [Chris AtLee] * Hash registers no longer leak into subcontexts as static registers (#1564) [Chris AtLee] * Fix `ParseTreeVisitor` for `with` variable expressions in `Render` tag (#1596) [CP Clermont] ### Changed * Liquid::Context#registers now always returns a Liquid::Registers object, though supports the most used Hash functions for compatibility (#1553) ## 5.3.0 2022-03-22 ### Fixes * StandardFilter: Fix missing @context on iterations (#1525) [Thierry Joyal] * Fix warning about block and default value in `static_registers.rb` (#1531) [Peter Zhu] ### Deprecation * Condition#evaluate to require mandatory context argument in Liquid 6.0.0 (#1527) [Thierry Joyal] ## 5.2.0 2022-03-01 ### Features * Add `remove_last`, and `replace_last` filters (#1422) [Anders Hagbard] * Eagerly cache global filters (#1524) [Jean Boussier] ### Fixes * Fix some internal errors in filters from invalid input (#1476) [Dylan Thacker-Smith] * Allow dash in filter kwarg name for consistency with Liquid::C (#1518) [CP Clermont] ## 5.1.0 / 2021-09-09 ### Features * Add `base64_encode`, `base64_decode`, `base64_url_safe_encode`, and `base64_url_safe_decode` filters (#1450) [Daniel Insley] * Introduce `to_liquid_value` in `Liquid::Drop` (#1441) [Michael Go] ### Fixes * Fix support for using a String subclass for the liquid source (#1421) [Dylan Thacker-Smith] * Add `ParseTreeVisitor` to `RangeLookup` (#1470) [CP Clermont] * Translate `RangeError` to `Liquid::Error` for `truncatewords` with large int (#1431) [Dylan Thacker-Smith] ## 5.0.1 / 2021-03-24 ### Fixes * Add ParseTreeVisitor to Echo tag (#1414) [CP Clermont] * Test with ruby 3.0 as the latest ruby version (#1398) [Dylan Thacker-Smith] * Handle carriage return in newlines_to_br (#1391) [Unending] ### Performance Improvements * Use split limit in truncatewords (#1361) [Dylan Thacker-Smith] ## 5.0.0 / 2021-01-06 ### Features * Add new `{% render %}` tag (#1122) [Samuel Doiron] * Add support for `as` in `{% render %}` and `{% include %}` (#1181) [Mike Angell] * Add `{% liquid %}` and `{% echo %}` tags (#1086) [Justin Li] * Add [usage tracking](README.md#usage-tracking) [Mike Angell] * Add `Tag.disable_tags` for disabling tags that prepend `Tag::Disableable` at render time (#1162, #1274, #1275) [Mike Angell] * Support using a profiler for multiple renders (#1365, #1366) [Dylan Thacker-Smith] ### Fixes * Fix catastrophic backtracking in `RANGES_REGEX` regular expression (#1357) [Dylan Thacker-Smith] * Make sure the for tag's limit and offset are integers (#1094) [David Cornu] * Invokable methods for enumerable reject include (#1151) [Thierry Joyal] * Allow `default` filter to handle `false` as value (#1144) [Mike Angell] * Fix render length resource limit so it doesn't multiply nested output (#1285) [Dylan Thacker-Smith] * Fix duplication of text in raw tags (#1304) [Peter Zhu] * Fix strict parsing of find variable with a name expression (#1317) [Dylan Thacker-Smith] * Use monotonic time to measure durations in Liquid::Profiler (#1362) [Dylan Thacker-Smith] ### Breaking Changes * Require Ruby >= 2.5 (#1131, #1310) [Mike Angell, Dylan Thacker-Smith] * Remove support for taint checking (#1268) [Dylan Thacker-Smith] * Split Strainer class into StrainerFactory and StrainerTemplate (#1208) [Thierry Joyal] * Remove handling of a nil context in the Strainer class (#1218) [Thierry Joyal] * Handle `BlockBody#blank?` at parse time (#1287) [Dylan Thacker-Smith] * Pass the tag markup and tokenizer to `Document#unknown_tag` (#1290) [Dylan Thacker-Smith] * And several internal changes ### Performance Improvements * Reduce allocations (#1073, #1091, #1115, #1099, #1117, #1141, #1322, #1341) [Richard Monette, Florian Weingarten, Ashwin Maroli] * Improve resources limits performance (#1093, #1323) [Florian Weingarten, Dylan Thacker-Smith] ## 4.0.3 / 2019-03-12 ### Fixed * Fix break and continue tags inside included templates in loops (#1072) [Justin Li] ## 4.0.2 / 2019-03-08 ### Changed * Add `where` filter (#1026) [Samuel Doiron] * Add `ParseTreeVisitor` to iterate the Liquid AST (#1025) [Stephen Paul Weber] * Improve `strip_html` performance (#1032) [printercu] ### Fixed * Add error checking for invalid combinations of inputs to sort, sort_natural, where, uniq, map, compact filters (#1059) [Garland Zhang] * Validate the character encoding in url_decode (#1070) [Clayton Smith] ## 4.0.1 / 2018-10-09 ### Changed * Add benchmark group in Gemfile (#855) [Jerry Liu] * Allow benchmarks to benchmark render by itself (#851) [Jerry Liu] * Avoid calling `line_number` on String node when rescuing a render error. (#860) [Dylan Thacker-Smith] * Avoid duck typing to detect whether to call render on a node. [Dylan Thacker-Smith] * Clarify spelling of `reversed` on `for` block tag (#843) [Mark Crossfield] * Replace recursion with loop to avoid potential stack overflow from malicious input (#891, #892) [Dylan Thacker-Smith] * Limit block tag nesting to 100 (#894) [Dylan Thacker-Smith] * Replace `assert_equal nil` with `assert_nil` (#895) [Dylan Thacker-Smith] * Remove Spy Gem (#896) [Dylan Thacker-Smith] * Add `collection_name` and `variable_name` reader to `For` block (#909) * Symbols render as strings (#920) [Justin Li] * Remove default value from Hash objects (#932) [Maxime Bedard] * Remove one level of nesting (#944) [Dylan Thacker-Smith] * Update Rubocop version (#952) [Justin Li] * Add `at_least` and `at_most` filters (#954, #958) [Nithin Bekal] * Add a regression test for a liquid-c trim mode bug (#972) [Dylan Thacker-Smith] * Use https rather than git protocol to fetch liquid-c [Dylan Thacker-Smith] * Add tests against Ruby 2.4 (#963) and 2.5 (#981) * Replace RegExp literals with constants (#988) [Ashwin Maroli] * Replace unnecessary `#each_with_index` with `#each` (#992) [Ashwin Maroli] * Improve the unexpected end delimiter message for block tags. (#1003) [Dylan Thacker-Smith] * Refactor and optimize rendering (#1005) [Christopher Aue] * Add installation instruction (#1006) [Ben Gift] * Remove Circle CI (#1010) * Rename deprecated `BigDecimal.new` to `BigDecimal` (#1024) [Koichi ITO] * Rename deprecated Rubocop name (#1027) [Justin Li] ### Fixed * Handle `join` filter on non String joiners (#857) [Richard Monette] * Fix duplicate inclusion condition logic error of `Liquid::Strainer.add_filter` method (#861) * Fix `escape`, `url_encode`, `url_decode` not handling non-string values (#898) [Thierry Joyal] * Fix raise when variable is defined but nil when using `strict_variables` [Pascal Betz] * Fix `sort` and `sort_natural` to handle arrays with nils (#930) [Eric Chan] ## 4.0.0 / 2016-12-14 / branch "4-0-stable" ### Changed * Render an opaque internal error by default for non-Liquid::Error (#835) [Dylan Thacker-Smith] * Ruby 2.0 support dropped (#832) [Dylan Thacker-Smith] * Add to_number Drop method to allow custom drops to work with number filters (#731) * Add strict_variables and strict_filters options to detect undefined references (#691) * Improve loop performance (#681) [Florian Weingarten] * Rename Drop method `before_method` to `liquid_method_missing` (#661) [Thierry Joyal] * Add url_decode filter to invert url_encode (#645) [Larry Archer] * Add global_filter to apply a filter to all output (#610) [Loren Hale] * Add compact filter (#600) [Carson Reinke] * Rename deprecated "has_key?" and "has_interrupt?" methods (#593) [Florian Weingarten] * Include template name with line numbers in render errors (574) [Dylan Thacker-Smith] * Add sort_natural filter (#554) [Martin Hanzel] * Add forloop.parentloop as a reference to the parent loop (#520) [Justin Li] * Block parsing moved to BlockBody class (#458) [Dylan Thacker-Smith] * Add concat filter to concatenate arrays (#429) [Diogo Beato] * Ruby 1.9 support dropped (#491) [Justin Li] * Liquid::Template.file_system's read_template_file method is no longer passed the context. (#441) [James Reid-Smith] * Remove `liquid_methods` (See https://github.com/Shopify/liquid/pull/568 for replacement) * Liquid::Template.register_filter raises when the module overrides registered public methods as private or protected (#705) [Gaurav Chande] ### Fixed * Fix variable names being detected as an operator when starting with contains (#788) [Michael Angell] * Fix include tag used with strict_variables (#828) [QuickPay] * Fix map filter when value is a Proc (#672) [Guillaume Malette] * Fix truncate filter when value is not a string (#672) [Guillaume Malette] * Fix behaviour of escape filter when input is nil (#665) [Tanel Jakobsoo] * Fix sort filter behaviour with empty array input (#652) [Marcel Cary] * Fix test failure under certain timezones (#631) [Dylan Thacker-Smith] * Fix bug in uniq filter (#595) [Florian Weingarten] * Fix bug when "blank" and "empty" are used as variable names (#592) [Florian Weingarten] * Fix condition parse order in strict mode (#569) [Justin Li] * Fix naming of the "context variable" when dynamically including a template (#559) [Justin Li] * Gracefully accept empty strings in the date filter (#555) [Loren Hale] * Fix capturing into variables with a hyphen in the name (#505) [Florian Weingarten] * Fix case sensitivity regression in date standard filter (#499) [Kelley Reynolds] * Disallow filters with no variable in strict mode (#475) [Justin Li] * Disallow variable names in the strict parser that are not valid in the lax parser (#463) [Justin Li] * Fix BlockBody#warnings taking exponential time to compute (#486) [Justin Li] ## 3.0.5 / 2015-07-23 / branch "3-0-stable" * Fix test failure under certain timezones [Dylan Thacker-Smith] ## 3.0.4 / 2015-07-17 * Fix chained access to multi-dimensional hashes [Florian Weingarten] ## 3.0.3 / 2015-05-28 * Fix condition parse order in strict mode (#569) [Justin Li] ## 3.0.2 / 2015-04-24 * Expose VariableLookup private members (#551) [Justin Li] * Documentation fixes ## 3.0.1 / 2015-01-23 * Remove duplicate `index0` key in TableRow tag (#502) [Alfred Xing] ## 3.0.0 / 2014-11-12 * Removed Block#end_tag. Instead, override parse with `super` followed by your code. See #446 [Dylan Thacker-Smith] * Fixed condition with wrong data types (#423) [Bogdan Gusiev] * Add url_encode to standard filters (#421) [Derrick Reimer] * Add uniq to standard filters [Florian Weingarten] * Add exception_handler feature (#397) and #254 [Bogdan Gusiev, Florian Weingarten] * Optimize variable parsing to avoid repeated regex evaluation during template rendering #383 [Jason Hiltz-Laforge] * Optimize checking for block interrupts to reduce object allocation #380 [Jason Hiltz-Laforge] * Properly set context rethrow_errors on render! #349 [Thierry Joyal] * Fix broken rendering of variables which are equal to false (#345) [Florian Weingarten] * Remove ActionView template handler [Dylan Thacker-Smith] * Freeze lots of string literals for new Ruby 2.1 optimization (#297) [Florian Weingarten] * Allow newlines in tags and variables (#324) [Dylan Thacker-Smith] * Tag#parse is called after initialize, which now takes options instead of tokens as the 3rd argument. See #321 [Dylan Thacker-Smith] * Raise `Liquid::ArgumentError` instead of `::ArgumentError` when filter has wrong number of arguments #309 [Bogdan Gusiev] * Add a to_s default for liquid drops (#306) [Adam Doeler] * Add strip, lstrip, and rstrip to standard filters [Florian Weingarten] * Make if, for & case tags return complete and consistent nodelists (#250) [Nick Jones] * Prevent arbitrary method invocation on condition objects (#274) [Dylan Thacker-Smith] * Don't call to_sym when creating conditions for security reasons (#273) [Bouke van der Bijl] * Fix resource counting bug with respond_to?(:length) (#263) [Florian Weingarten] * Allow specifying custom patterns for template filenames (#284) [Andrei Gladkyi] * Allow drops to optimize loading a slice of elements (#282) [Tom Burns] * Support for passing variables to snippets in subdirs (#271) [Joost Hietbrink] * Add a class cache to avoid runtime extend calls (#249) [James Tucker] * Remove some legacy Ruby 1.8 compatibility code (#276) [Florian Weingarten] * Add default filter to standard filters (#267) [Derrick Reimer] * Add optional strict parsing and warn parsing (#235) [Tristan Hume] * Add I18n syntax error translation (#241) [Simon Hørup Eskildsen, Sirupsen] * Make sort filter work on enumerable drops (#239) [Florian Weingarten] * Fix clashing method names in enumerable drops (#238) [Florian Weingarten] * Make map filter work on enumerable drops (#233) [Florian Weingarten] * Improved whitespace stripping for blank blocks, related to #216 [Florian Weingarten] ## 2.6.3 / 2015-07-23 / branch "2-6-stable" * Fix test failure under certain timezones [Dylan Thacker-Smith] ## 2.6.2 / 2015-01-23 * Remove duplicate hash key [Parker Moore] ## 2.6.1 / 2014-01-10 Security fix, cherry-picked from master (4e14a65): * Don't call to_sym when creating conditions for security reasons (#273) [Bouke van der Bijl] * Prevent arbitrary method invocation on condition objects (#274) [Dylan Thacker-Smith] ## 2.6.0 / 2013-11-25 IMPORTANT: Liquid 2.6 is going to be the last version of Liquid which maintains explicit Ruby 1.8 compatability. The following releases will only be tested against Ruby 1.9 and Ruby 2.0 and are likely to break on Ruby 1.8. * Bugfix for #106: fix example servlet [gnowoel] * Bugfix for #97: strip_html filter supports multi-line tags [Jo Liss] * Bugfix for #114: strip_html filter supports style tags [James Allardice] * Bugfix for #117: 'now' support for date filter in Ruby 1.9 [Notre Dame Webgroup] * Bugfix for #166: truncate filter on UTF-8 strings with Ruby 1.8 [Florian Weingarten] * Bugfix for #204: 'raw' parsing bug [Florian Weingarten] * Bugfix for #150: 'for' parsing bug [Peter Schröder] * Bugfix for #126: Strip CRLF in strip_newline [Peter Schröder] * Bugfix for #174, "can't convert Fixnum into String" for "replace" [jsw0528] * Allow a Liquid::Drop to be passed into Template#render [Daniel Huckstep] * Resource limits [Florian Weingarten] * Add reverse filter [Jay Strybis] * Add utf-8 support * Use array instead of Hash to keep the registered filters [Tasos Stathopoulos] * Cache tokenized partial templates [Tom Burns] * Avoid warnings in Ruby 1.9.3 [Marcus Stollsteimer] * Better documentation for 'include' tag (closes #163) [Peter Schröder] * Use of BigDecimal on filters to have better precision (closes #155) [Arthur Nogueira Neves] ## 2.5.5 / 2014-01-10 / branch "2-5-stable" Security fix, cherry-picked from master (4e14a65): * Don't call to_sym when creating conditions for security reasons (#273) [Bouke van der Bijl] * Prevent arbitrary method invocation on condition objects (#274) [Dylan Thacker-Smith] ## 2.5.4 / 2013-11-11 * Fix "can't convert Fixnum into String" for "replace" (#173), [jsw0528] ## 2.5.3 / 2013-10-09 * #232, #234, #237: Fix map filter bugs [Florian Weingarten] ## 2.5.2 / 2013-09-03 / deleted Yanked from rubygems, as it contained too many changes that broke compatibility. Those changes will be on following major releases. ## 2.5.1 / 2013-07-24 * #230: Fix security issue with map filter, Use invoke_drop in map filter [Florian Weingarten] ## 2.5.0 / 2013-03-06 * Prevent Object methods from being called on drops * Avoid symbol injection from liquid * Added break and continue statements * Fix filter parser for args without space separators * Add support for filter keyword arguments ## 2.4.0 / 2012-08-03 * Performance improvements * Allow filters in `assign` * Add `modulo` filter * Ruby 1.8, 1.9, and Rubinius compatibility fixes * Add support for `quoted['references']` in `tablerow` * Add support for Enumerable to `tablerow` * `strip_html` filter removes html comments ## 2.3.0 / 2011-10-16 * Several speed/memory improvements * Numerous bug fixes * Added support for MRI 1.9, Rubinius, and JRuby * Added support for integer drop parameters * Added epoch support to `date` filter * New `raw` tag that suppresses parsing * Added `else` option to `for` tag * New `increment` tag * New `split` filter ## 2.2.1 / 2010-08-23 * Added support for literal tags ## 2.2.0 / 2010-08-22 * Compatible with Ruby 1.8.7, 1.9.1 and 1.9.2-p0 * Merged some changed made by the community ## 1.9.0 / 2008-03-04 * Fixed gem install rake task * Improve Error encapsulation in liquid by maintaining a own set of exceptions instead of relying on ruby build ins ## Before 1.9.0 * Added If with or / and expressions * Implemented .to_liquid for all objects which can be passed to liquid like Strings Arrays Hashes Numerics and Booleans. To export new objects to liquid just implement .to_liquid on them and return objects which themselves have .to_liquid methods. * Added more tags to standard library * Added include tag ( like partials in rails ) * [...] Gazillion of detail improvements * Added strainers as filter hosts for better security [Tobias Luetke] * Fixed that rails integration would call filter with the wrong "self" [Michael Geary] * Fixed bad error reporting when a filter called a method which doesn't exist. Liquid told you that it couldn't find the filter which was obviously misleading [Tobias Luetke] * Removed count helper from standard lib. use size [Tobias Luetke] * Fixed bug with string filter parameters failing to tolerate commas in strings. [Paul Hammond] * Improved filter parameters. Filter parameters are now context sensitive; Types are resolved according to the rules of the context. Multiple parameters are now separated by the Liquid::ArgumentSeparator: , by default [Paul Hammond] {{ 'Typo' | link_to: 'http://typo.leetsoft.com', 'Typo - a modern weblog engine' }} * Added Liquid::Drop. A base class which you can use for exporting proxy objects to liquid which can acquire more data when used in liquid. [Tobias Luetke] class ProductDrop < Liquid::Drop def top_sales Shop.current.products.find(:all, :order => 'sales', :limit => 10 ) end end t = Liquid::Template.parse( ' {% for product in product.top_sales %} {{ product.name }} {% endfor %} ' ) t.render('product' => ProductDrop.new ) * Added filter parameters support. Example: {{ date | format_date: "%Y" }} [Paul Hammond] ================================================ FILE: LICENSE ================================================ Copyright (c) 2005, 2006 Tobias Luetke Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ [![Build status](https://github.com/Shopify/liquid/actions/workflows/liquid.yml/badge.svg)](https://github.com/Shopify/liquid/actions/workflows/liquid.yml) [![Inline docs](http://inch-ci.org/github/Shopify/liquid.svg?branch=master)](http://inch-ci.org/github/Shopify/liquid) # Liquid template engine * [Contributing guidelines](CONTRIBUTING.md) * [Version history](History.md) * [Liquid documentation from Shopify](https://shopify.dev/docs/api/liquid) * [Liquid Wiki at GitHub](https://github.com/Shopify/liquid/wiki) * [Website](http://liquidmarkup.org/) ## Introduction Liquid is a template engine which was written with very specific requirements: * It has to have beautiful and simple markup. Template engines which don't produce good looking markup are no fun to use. * It needs to be non evaling and secure. Liquid templates are made so that users can edit them. You don't want to run code on your server which your users wrote. * It has to be stateless. Compile and render steps have to be separate so that the expensive parsing and compiling can be done once and later on you can just render it passing in a hash with local variables and objects. ## Why you should use Liquid * You want to allow your users to edit the appearance of your application but don't want them to run **insecure code on your server**. * You want to render templates directly from the database. * You like smarty (PHP) style template engines. * You need a template engine which does HTML just as well as emails. * You don't like the markup of your current templating engine. ## What does it look like? ```html ``` ## How to use Liquid Install Liquid by adding `gem 'liquid'` to your gemfile. Liquid supports a very simple API based around the Liquid::Template class. For standard use you can just pass it the content of a file and call render with a parameters hash. ```ruby @template = Liquid::Template.parse("hi {{name}}") # Parses and compiles the template @template.render('name' => 'tobi') # => "hi tobi" ``` ### Concept of Environments In Liquid, a "Environment" is a scoped environment that encapsulates custom tags, filters, and other configurations. This allows you to define and isolate different sets of functionality for different contexts, avoiding global overrides that can lead to conflicts and unexpected behavior. By using environments, you can: 1. **Encapsulate Logic**: Keep the logic for different parts of your application separate. 2. **Avoid Conflicts**: Prevent custom tags and filters from clashing with each other. 3. **Improve Maintainability**: Make it easier to manage and understand the scope of customizations. 4. **Enhance Security**: Limit the availability of certain tags and filters to specific contexts. We encourage the use of Environments over globally overriding things because it promotes better software design principles such as modularity, encapsulation, and separation of concerns. Here's an example of how you can define and use Environments in Liquid: ```ruby user_environment = Liquid::Environment.build do |environment| environment.register_tag("renderobj", RenderObjTag) end Liquid::Template.parse(<<~LIQUID, environment: user_environment) {% renderobj src: "path/to/model.obj" %} LIQUID ``` In this example, `RenderObjTag` is a custom tag that is only available within the `user_environment`. Similarly, you can define another environment for a different context, such as email templates: ```ruby email_environment = Liquid::Environment.build do |environment| environment.register_tag("unsubscribe_footer", UnsubscribeFooter) end Liquid::Template.parse(<<~LIQUID, environment: email_environment) {% unsubscribe_footer %} LIQUID ``` By using Environments, you ensure that custom tags and filters are only available in the contexts where they are needed, making your Liquid templates more robust and easier to manage. For smaller projects, a global environment is available via `Liquid::Environment.default`. ### Error Modes Setting the error mode of Liquid lets you specify how strictly you want your templates to be interpreted. Normally the parser is very lax and will accept almost anything without error. Unfortunately this can make it very hard to debug and can lead to unexpected behaviour. Liquid also comes with different parsers that can be used when editing templates to give better error messages when templates are invalid. You can enable this new parser like this: ```ruby Liquid::Environment.default.error_mode = :strict2 # Raises a SyntaxError when invalid syntax is used in all tags Liquid::Environment.default.error_mode = :strict # Raises a SyntaxError when invalid syntax is used in some tags Liquid::Environment.default.error_mode = :warn # Adds strict errors to template.errors but continues as normal Liquid::Environment.default.error_mode = :lax # The default mode, accepts almost anything. ``` If you want to set the error mode only on specific templates you can pass `:error_mode` as an option to `parse`: ```ruby Liquid::Template.parse(source, error_mode: :strict) ``` This is useful for doing things like enabling strict mode only in the theme editor. It is recommended that you enable `:strict` or `:warn` mode on new apps to stop invalid templates from being created. It is also recommended that you use it in the template editors of existing apps to give editors better error messages. ### Undefined variables and filters By default, the renderer doesn't raise or in any other way notify you if some variables or filters are missing, i.e. not passed to the `render` method. You can improve this situation by passing `strict_variables: true` and/or `strict_filters: true` options to the `render` method. When one of these options is set to true, all errors about undefined variables and undefined filters will be stored in `errors` array of a `Liquid::Template` instance. Here are some examples: ```ruby template = Liquid::Template.parse("{{x}} {{y}} {{z.a}} {{z.b}}") template.render({ 'x' => 1, 'z' => { 'a' => 2 } }, { strict_variables: true }) #=> '1 2 ' # when a variable is undefined, it's rendered as nil template.errors #=> [#, #] ``` ```ruby template = Liquid::Template.parse("{{x | filter1 | upcase}}") template.render({ 'x' => 'foo' }, { strict_filters: true }) #=> '' # when at least one filter in the filter chain is undefined, a whole expression is rendered as nil template.errors #=> [#] ``` If you want to raise on a first exception instead of pushing all of them in `errors`, you can use `render!` method: ```ruby template = Liquid::Template.parse("{{x}} {{y}}") template.render!({ 'x' => 1}, { strict_variables: true }) #=> Liquid::UndefinedVariable: Liquid error: undefined variable y ``` ### Usage tracking To help track usages of a feature or code path in production, we have released opt-in usage tracking. To enable this, we provide an empty `Liquid:: Usage.increment` method which you can customize to your needs. The feature is well suited to https://github.com/Shopify/statsd-instrument. However, the choice of implementation is up to you. Once you have enabled usage tracking, we recommend reporting any events through Github Issues that your system may be logging. It is highly likely this event has been added to consider deprecating or improving code specific to this event, so please raise any concerns. ================================================ FILE: Rakefile ================================================ # frozen_string_literal: true require 'rake' require 'rake/testtask' $LOAD_PATH.unshift(File.expand_path("../lib", __FILE__)) require "liquid/version" task(default: [:test, :rubocop]) desc('run test suite with default parser') Rake::TestTask.new(:base_test) do |t| t.libs << 'lib' << 'test' t.test_files = FileList['test/{integration,unit}/**/*_test.rb'] t.verbose = false end Rake::TestTask.new(:integration_test) do |t| t.libs << 'lib' << 'test' t.test_files = FileList['test/integration/**/*_test.rb'] t.verbose = false end desc('run test suite with warn error mode') task :warn_test do ENV['LIQUID_PARSER_MODE'] = 'warn' Rake::Task['base_test'].invoke end task :rubocop do if RUBY_ENGINE == 'ruby' require 'rubocop/rake_task' RuboCop::RakeTask.new end end desc('runs test suite with lax, strict, and strict2 parsers') task :test do ENV['LIQUID_PARSER_MODE'] = 'lax' Rake::Task['base_test'].invoke ENV['LIQUID_PARSER_MODE'] = 'strict' Rake::Task['base_test'].reenable Rake::Task['base_test'].invoke ENV['LIQUID_PARSER_MODE'] = 'strict2' Rake::Task['base_test'].reenable Rake::Task['base_test'].invoke if RUBY_ENGINE == 'ruby' || RUBY_ENGINE == 'truffleruby' ENV['LIQUID_PARSER_MODE'] = 'lax' Rake::Task['integration_test'].reenable Rake::Task['integration_test'].invoke ENV['LIQUID_PARSER_MODE'] = 'strict' Rake::Task['integration_test'].reenable Rake::Task['integration_test'].invoke ENV['LIQUID_PARSER_MODE'] = 'strict2' Rake::Task['integration_test'].reenable Rake::Task['integration_test'].invoke end end task(gem: :build) task :build do system "gem build liquid.gemspec" end task install: :build do system "gem install liquid-#{Liquid::VERSION}.gem" end task release: :build do system "git tag -a v#{Liquid::VERSION} -m 'Tagging #{Liquid::VERSION}'" system "git push --tags" system "gem push liquid-#{Liquid::VERSION}.gem" system "rm liquid-#{Liquid::VERSION}.gem" end namespace :benchmark do desc "Run the liquid benchmark with lax parsing" task :lax do ruby "./performance/benchmark.rb lax" end desc "Run the liquid benchmark with strict parsing" task :strict do ruby "./performance/benchmark.rb strict" end desc "Run the liquid benchmark with strict2 parsing" task :strict2 do ruby "./performance/benchmark.rb strict2" end desc "Run the liquid benchmark with lax, strict, and strict2 parsing" task run: [:lax, :strict, :strict2] desc "Run unit benchmarks" namespace :unit do task :all do Dir["./performance/unit/*_benchmark.rb"].each do |file| puts "🧪 Running #{file}" ruby file end end task :lexer do Dir["./performance/unit/lexer_benchmark.rb"].each do |file| puts "🧪 Running #{file}" ruby file end end task :expression do Dir["./performance/unit/expression_benchmark.rb"].each do |file| puts "🧪 Running #{file}" ruby file end end end end namespace :profile do desc "Run the liquid profile/performance coverage" task :run do ruby "./performance/profile.rb" end desc "Run the liquid profile/performance coverage with strict parsing" task :strict do ruby "./performance/profile.rb strict" end end namespace :memory_profile do desc "Run memory profiler" task :run do ruby "./performance/memory_profile.rb" end end desc("Run example") task :example do ruby "-w -d -Ilib example/server/server.rb" end task :console do exec 'irb -I lib -r liquid' end desc('run liquid-spec suite across all adapters') task :spec do adapters = Dir['./spec/*.rb'].join(',') sh "bundle exec liquid-spec matrix --adapters=#{adapters} --reference=ruby_liquid" end ================================================ FILE: bin/render ================================================ #!/usr/bin/env ruby # frozen_string_literal: true require 'bundler/setup' require 'liquid' class VirtualFileSystem def initialize snippet_1 = <<~LIQUID

{{- greating | default: 'Hello' }}, {{ name | default: 'world' -}}!

LIQUID snippet_2 = <<~LIQUID {%- for i in (1..5) -%} > {{ i }} {%- endfor -%} LIQUID @templates = { 'snippet-1' => snippet_1, 'snippet-2' => snippet_2, } end def read_template_file(key) @templates[key] || raise(Liquid::FileSystemError, "No such template '#{key}'") end end def source File.read(ARGV[0]) rescue StandardError 'Usage: bin/render example/server/templates/index.liquid' end def assigns { 'date' => Time.now, } end puts Liquid::Template .parse(source, error_mode: :strict2) .tap { |t| t.registers[:file_system] = VirtualFileSystem.new } .render(assigns) ================================================ FILE: example/server/example_servlet.rb ================================================ # frozen_string_literal: true module ProductsFilter def price(integer) format("$%.2d USD", integer / 100.0) end def prettyprint(text) text.gsub(/\*(.*)\*/, '\1') end def count(array) array.size end def paragraph(p) "

#{p}

" end end class Servlet < LiquidServlet def index { 'date' => Time.now } end def products { 'products' => products_list, 'more_products' => more_products_list, 'description' => description, 'section' => 'Snowboards', 'cool_products' => true } end private def products_list [ { 'name' => 'Arbor Draft', 'price' => 39900, 'description' => 'the *arbor draft* is a excellent product' }, { 'name' => 'Arbor Element', 'price' => 40000, 'description' => 'the *arbor element* rocks for freestyling' }, { 'name' => 'Arbor Diamond', 'price' => 59900, 'description' => 'the *arbor diamond* is a made up product because im obsessed with arbor and have no creativity' } ] end def more_products_list [ { 'name' => 'Arbor Catalyst', 'price' => 39900, 'description' => 'the *arbor catalyst* is an advanced drop-through for freestyle and flatground performance and versatility' }, { 'name' => 'Arbor Fish', 'price' => 40000, 'description' => 'the *arbor fish* is a compact pin that features an extended wheelbase and time-honored teardrop shape' } ] end def description "List of Products ~ This is a list of products with price and description." end end ================================================ FILE: example/server/liquid_servlet.rb ================================================ # frozen_string_literal: true class LiquidServlet < WEBrick::HTTPServlet::AbstractServlet def do_GET(req, res) handle(:get, req, res) end def do_POST(req, res) handle(:post, req, res) end private def handle(_type, req, res) @request = req @response = res @request.path_info =~ /(\w+)\z/ @action = Regexp.last_match(1) || 'index' @assigns = send(@action) if respond_to?(@action) @response['Content-Type'] = "text/html" @response.status = 200 @response.body = Liquid::Template.parse(read_template).render(@assigns, filters: [ProductsFilter]) end def read_template(filename = @action) File.read("#{__dir__}/templates/#{filename}.liquid") end end ================================================ FILE: example/server/server.rb ================================================ # frozen_string_literal: true require 'webrick' require 'rexml/document' require_relative '../../lib/liquid' require_relative 'liquid_servlet' require_relative 'example_servlet' # Setup webrick server = WEBrick::HTTPServer.new(Port: ARGV[1] || 3000) server.mount('/', Servlet) trap("INT") { server.shutdown } server.start ================================================ FILE: example/server/templates/index.liquid ================================================

Hello world!

It is {{date}}

Check out the Products screen

================================================ FILE: example/server/templates/products.liquid ================================================ products {% assign all_products = products | concat: more_products %}

{{ description | split: '~' | first }}

{{ description | split: '~' | last }}

There are currently {{all_products | count}} products in the {{section}} catalog

{% if cool_products %} Cool products :) {% else %} Uncool products :( {% endif %}
    {% for product in all_products %}
  • {{product.name}}

    Only {{product.price | price }} {{product.description | prettyprint | paragraph }} {{ 'it rocks!' | paragraph }}
  • {% endfor %}
================================================ FILE: lib/liquid/block.rb ================================================ # frozen_string_literal: true module Liquid class Block < Tag MAX_DEPTH = 100 def initialize(tag_name, markup, options) super @blank = true end def parse(tokens) @body = new_body while parse_body(@body, tokens) end @body.freeze end # For backwards compatibility def render(context) @body.render(context) end def blank? @blank end def nodelist @body.nodelist end def unknown_tag(tag_name, _markup, _tokenizer) Block.raise_unknown_tag(tag_name, block_name, block_delimiter, parse_context) end # @api private def self.raise_unknown_tag(tag, block_name, block_delimiter, parse_context) if tag == 'else' raise SyntaxError, parse_context.locale.t( "errors.syntax.unexpected_else", block_name: block_name, ) elsif tag.start_with?('end') raise SyntaxError, parse_context.locale.t( "errors.syntax.invalid_delimiter", tag: tag, block_name: block_name, block_delimiter: block_delimiter, ) else raise SyntaxError, parse_context.locale.t("errors.syntax.unknown_tag", tag: tag) end end def raise_tag_never_closed(block_name) raise SyntaxError, parse_context.locale.t("errors.syntax.tag_never_closed", block_name: block_name) end def block_name @tag_name end def block_delimiter @block_delimiter ||= "end#{block_name}" end private # @api public def new_body parse_context.new_block_body end # @api public def parse_body(body, tokens) if parse_context.depth >= MAX_DEPTH raise StackLevelError, "Nesting too deep" end parse_context.depth += 1 begin body.parse(tokens, parse_context) do |end_tag_name, end_tag_params| @blank &&= body.blank? return false if end_tag_name == block_delimiter raise_tag_never_closed(block_name) unless end_tag_name # this tag is not registered with the system # pass it to the current block for special handling or error reporting unknown_tag(end_tag_name, end_tag_params, tokens) end ensure parse_context.depth -= 1 end true end end end ================================================ FILE: lib/liquid/block_body.rb ================================================ # frozen_string_literal: true require 'English' module Liquid class BlockBody LiquidTagToken = /\A\s*(#{TagName})\s*(.*?)\z/o FullToken = /\A#{TagStart}#{WhitespaceControl}?(\s*)(#{TagName})(\s*)(.*?)#{WhitespaceControl}?#{TagEnd}\z/om FullTokenPossiblyInvalid = /\A(.*)#{TagStart}#{WhitespaceControl}?\s*(\w+)\s*(.*)?#{WhitespaceControl}?#{TagEnd}\z/om ContentOfVariable = /\A#{VariableStart}#{WhitespaceControl}?(.*?)#{WhitespaceControl}?#{VariableEnd}\z/om WhitespaceOrNothing = /\A\s*\z/ TAGSTART = "{%" VARSTART = "{{" attr_reader :nodelist def initialize @nodelist = [] @blank = true end def parse(tokenizer, parse_context, &block) raise FrozenError, "can't modify frozen Liquid::BlockBody" if frozen? parse_context.line_number = tokenizer.line_number if tokenizer.for_liquid_tag parse_for_liquid_tag(tokenizer, parse_context, &block) else parse_for_document(tokenizer, parse_context, &block) end end def freeze @nodelist.freeze super end private def parse_for_liquid_tag(tokenizer, parse_context) while (token = tokenizer.shift) unless token.empty? || token.match?(WhitespaceOrNothing) unless token =~ LiquidTagToken # line isn't empty but didn't match tag syntax, yield and let the # caller raise a syntax error return yield token, token end tag_name = Regexp.last_match(1) markup = Regexp.last_match(2) if tag_name == 'liquid' parse_context.line_number -= 1 next parse_liquid_tag(markup, parse_context) end unless (tag = parse_context.environment.tag_for_name(tag_name)) # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed return yield tag_name, markup end new_tag = tag.parse(tag_name, markup, tokenizer, parse_context) @blank &&= new_tag.blank? @nodelist << new_tag end parse_context.line_number = tokenizer.line_number end yield nil, nil end # @api private def self.unknown_tag_in_liquid_tag(tag, parse_context) Block.raise_unknown_tag(tag, 'liquid', '%}', parse_context) end # @api private def self.raise_missing_tag_terminator(token, parse_context) raise SyntaxError, parse_context.locale.t("errors.syntax.tag_termination", token: token, tag_end: TagEnd.inspect) end # @api private def self.raise_missing_variable_terminator(token, parse_context) raise SyntaxError, parse_context.locale.t("errors.syntax.variable_termination", token: token, tag_end: VariableEnd.inspect) end # @api private def self.render_node(context, output, node) node.render_to_output_buffer(context, output) rescue => exc blank_tag = !node.instance_of?(Variable) && node.blank? rescue_render_node(context, output, node.line_number, exc, blank_tag) end # @api private def self.rescue_render_node(context, output, line_number, exc, blank_tag) case exc when MemoryError raise when UndefinedVariable, UndefinedDropMethod, UndefinedFilter context.handle_error(exc, line_number) else error_message = context.handle_error(exc, line_number) unless blank_tag # conditional for backwards compatibility output << error_message end end end private def parse_liquid_tag(markup, parse_context) liquid_tag_tokenizer = parse_context.new_tokenizer( markup, start_line_number: parse_context.line_number, for_liquid_tag: true ) parse_for_liquid_tag(liquid_tag_tokenizer, parse_context) do |end_tag_name, _end_tag_markup| if end_tag_name BlockBody.unknown_tag_in_liquid_tag(end_tag_name, parse_context) end end end private def handle_invalid_tag_token(token, parse_context) if token.end_with?('%}') yield token, token else BlockBody.raise_missing_tag_terminator(token, parse_context) end end private def parse_for_document(tokenizer, parse_context, &block) while (token = tokenizer.shift) next if token.empty? case when token.start_with?(TAGSTART) whitespace_handler(token, parse_context) unless token =~ FullToken return handle_invalid_tag_token(token, parse_context, &block) end tag_name = Regexp.last_match(2) markup = Regexp.last_match(4) if parse_context.line_number # newlines inside the tag should increase the line number, # particularly important for multiline {% liquid %} tags parse_context.line_number += Regexp.last_match(1).count("\n") + Regexp.last_match(3).count("\n") end if tag_name == 'liquid' parse_liquid_tag(markup, parse_context) next end unless (tag = parse_context.environment.tag_for_name(tag_name)) # end parsing if we reach an unknown tag and let the caller decide # determine how to proceed return yield tag_name, markup end new_tag = tag.parse(tag_name, markup, tokenizer, parse_context) @blank &&= new_tag.blank? @nodelist << new_tag when token.start_with?(VARSTART) whitespace_handler(token, parse_context) @nodelist << create_variable(token, parse_context) @blank = false else if parse_context.trim_whitespace token.lstrip! end parse_context.trim_whitespace = false @nodelist << token @blank &&= token.match?(WhitespaceOrNothing) end parse_context.line_number = tokenizer.line_number end yield nil, nil end def whitespace_handler(token, parse_context) if token[2] == WhitespaceControl previous_token = @nodelist.last if previous_token.is_a?(String) first_byte = previous_token.getbyte(0) previous_token.rstrip! if previous_token.empty? && parse_context[:bug_compatible_whitespace_trimming] && first_byte previous_token << first_byte end end end parse_context.trim_whitespace = (token[-3] == WhitespaceControl) end def blank? @blank end # Remove blank strings in the block body for a control flow tag (e.g. `if`, `for`, `case`, `unless`) # with a blank body. # # For example, in a conditional assignment like the following # # ``` # {% if size > max_size %} # {% assign size = max_size %} # {% endif %} # ``` # # we assume the intention wasn't to output the blank spaces in the `if` tag's block body, so this method # will remove them to reduce the render output size. # # Note that it is now preferred to use the `liquid` tag for this use case. def remove_blank_strings raise "remove_blank_strings only support being called on a blank block body" unless @blank @nodelist.reject! { |node| node.instance_of?(String) } end def render(context) render_to_output_buffer(context, +'') end def render_to_output_buffer(context, output) freeze unless frozen? context.resource_limits.increment_render_score(@nodelist.length) idx = 0 while (node = @nodelist[idx]) if node.instance_of?(String) output << node else render_node(context, output, node) # If we get an Interrupt that means the block must stop processing. An # Interrupt is any command that stops block execution such as {% break %} # or {% continue %}. These tags may also occur through Block or Include tags. break if context.interrupt? # might have happened in a for-block end idx += 1 context.resource_limits.increment_write_score(output) end output end private def render_node(context, output, node) BlockBody.render_node(context, output, node) end def create_variable(token, parse_context) if token.end_with?("}}") i = 2 i = 3 if token[i] == "-" parse_end = token.length - 3 parse_end -= 1 if token[parse_end] == "-" markup_end = parse_end - i + 1 markup = markup_end <= 0 ? "" : token.slice(i, markup_end) return Variable.new(markup, parse_context) end BlockBody.raise_missing_variable_terminator(token, parse_context) end # @deprecated Use {.raise_missing_tag_terminator} instead def raise_missing_tag_terminator(token, parse_context) BlockBody.raise_missing_tag_terminator(token, parse_context) end # @deprecated Use {.raise_missing_variable_terminator} instead def raise_missing_variable_terminator(token, parse_context) BlockBody.raise_missing_variable_terminator(token, parse_context) end end end ================================================ FILE: lib/liquid/condition.rb ================================================ # frozen_string_literal: true module Liquid # Container for liquid nodes which conveniently wraps decision making logic # # Example: # # c = Condition.new(1, '==', 1) # c.evaluate #=> true # class Condition # :nodoc: @@operators = { '==' => ->(cond, left, right) { cond.send(:equal_variables, left, right) }, '!=' => ->(cond, left, right) { !cond.send(:equal_variables, left, right) }, '<>' => ->(cond, left, right) { !cond.send(:equal_variables, left, right) }, '<' => :<, '>' => :>, '>=' => :>=, '<=' => :<=, 'contains' => lambda do |_cond, left, right| if left && right && left.respond_to?(:include?) right = right.to_s if left.is_a?(String) left.include?(right) else false end rescue Encoding::CompatibilityError # "✅".b.include?("✅") raises Encoding::CompatibilityError despite being materially equal left.b.include?(right.b) end, } class MethodLiteral attr_reader :method_name, :to_s def initialize(method_name, to_s) @method_name = method_name @to_s = to_s end end @@method_literals = { 'blank' => MethodLiteral.new(:blank?, '').freeze, 'empty' => MethodLiteral.new(:empty?, '').freeze, } def self.operators @@operators end def self.parse_expression(parse_context, markup, safe: false) @@method_literals[markup] || parse_context.parse_expression(markup, safe: safe) end attr_reader :attachment, :child_condition attr_accessor :left, :operator, :right def initialize(left = nil, operator = nil, right = nil) @left = left @operator = operator @right = right @child_relation = nil @child_condition = nil end def evaluate(context = deprecated_default_context) condition = self result = nil loop do result = interpret_condition(condition.left, condition.right, condition.operator, context) case condition.child_relation when :or break if Liquid::Utils.to_liquid_value(result) when :and break unless Liquid::Utils.to_liquid_value(result) else break end condition = condition.child_condition end result end def or(condition) @child_relation = :or @child_condition = condition end def and(condition) @child_relation = :and @child_condition = condition end def attach(attachment) @attachment = attachment end def else? false end def inspect "#" end protected attr_reader :child_relation private def equal_variables(left, right) if left.is_a?(MethodLiteral) return call_method_literal(left, right) end if right.is_a?(MethodLiteral) return call_method_literal(right, left) end left == right end def call_method_literal(literal, value) method_name = literal.method_name # If the object responds to the method (e.g., ActiveSupport is loaded), use it if value.respond_to?(method_name) value.send(method_name) else # Emulate ActiveSupport's blank?/empty? to make Liquid invariant # to whether ActiveSupport is loaded or not case method_name when :blank? liquid_blank?(value) when :empty? liquid_empty?(value) else false end end end # Implement blank? semantics matching ActiveSupport # blank? returns true for nil, false, empty strings, whitespace-only strings, # empty arrays, and empty hashes def liquid_blank?(value) case value when NilClass, FalseClass true when TrueClass, Numeric false when String # Blank if empty or whitespace only (matches ActiveSupport) value.empty? || value.match?(/\A\s*\z/) when Array, Hash value.empty? else # Fall back to empty? if available, otherwise false value.respond_to?(:empty?) ? value.empty? : false end end # Implement empty? semantics # Note: nil is NOT empty. empty? checks if a collection has zero elements. def liquid_empty?(value) case value when String, Array, Hash value.empty? else value.respond_to?(:empty?) ? value.empty? : false end end def interpret_condition(left, right, op, context) # If the operator is empty this means that the decision statement is just # a single variable. We can just poll this variable from the context and # return this as the result. return context.evaluate(left) if op.nil? left = Liquid::Utils.to_liquid_value(context.evaluate(left)) right = Liquid::Utils.to_liquid_value(context.evaluate(right)) operation = self.class.operators[op] || raise(Liquid::ArgumentError, "Unknown operator #{op}") if operation.respond_to?(:call) operation.call(self, left, right) elsif left.respond_to?(operation) && right.respond_to?(operation) && !left.is_a?(Hash) && !right.is_a?(Hash) begin left.send(operation, right) rescue ::ArgumentError => e raise Liquid::ArgumentError, e.message end end end def deprecated_default_context warn("DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \ "and will be removed from Liquid 6.0.0.") Context.new end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [ @node.left, @node.right, @node.child_condition, @node.attachment ].compact end end end class ElseCondition < Condition def else? true end def evaluate(_context) true end end end ================================================ FILE: lib/liquid/const.rb ================================================ # frozen_string_literal: true module Liquid module Const EMPTY_HASH = {}.freeze EMPTY_ARRAY = [].freeze end end ================================================ FILE: lib/liquid/context.rb ================================================ # frozen_string_literal: true module Liquid # Context keeps the variable stack and resolves variables, as well as keywords # # context['variable'] = 'testing' # context['variable'] #=> 'testing' # context['true'] #=> true # context['10.2232'] #=> 10.2232 # # context.stack do # context['bob'] = 'bobsen' # end # # context['bob'] #=> nil class Context class Context attr_reader :scopes, :errors, :registers, :environments, :resource_limits, :static_registers, :static_environments attr_accessor :exception_renderer, :template_name, :partial, :global_filter, :strict_variables, :strict_filters, :environment # rubocop:disable Metrics/ParameterLists def self.build(environment: Environment.default, environments: {}, outer_scope: {}, registers: {}, rethrow_errors: false, resource_limits: nil, static_environments: {}, &block) new(environments, outer_scope, registers, rethrow_errors, resource_limits, static_environments, environment, &block) end def initialize(environments = {}, outer_scope = {}, registers = {}, rethrow_errors = false, resource_limits = nil, static_environments = {}, environment = Environment.default) @environment = environment @environments = [environments] @environments.flatten! @static_environments = [static_environments].flatten(1).freeze @scopes = [outer_scope || {}] @registers = registers.is_a?(Registers) ? registers : Registers.new(registers) @errors = [] @partial = false @strict_variables = false @resource_limits = resource_limits || ResourceLimits.new(environment.default_resource_limits) @base_scope_depth = 0 @interrupts = [] @filters = [] @global_filter = nil @disabled_tags = {} # Instead of constructing new StringScanner objects for each Expression parse, # we recycle the same one. @string_scanner = StringScanner.new("") @registers.static[:cached_partials] ||= {} @registers.static[:file_system] ||= environment.file_system @registers.static[:template_factory] ||= Liquid::TemplateFactory.new self.exception_renderer = environment.exception_renderer if rethrow_errors self.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA end yield self if block_given? # Do this last, since it could result in this object being passed to a Proc in the environment squash_instance_assigns_with_environments end # rubocop:enable Metrics/ParameterLists def warnings @warnings ||= [] end def strainer @strainer ||= @environment.create_strainer(self, @filters) end # Adds filters to this context. # # Note that this does not register the filters with the main Template object. see Template.register_filter # for that def add_filters(filters) filters = [filters].flatten.compact @filters += filters @strainer = nil end def apply_global_filter(obj) global_filter.nil? ? obj : global_filter.call(obj) end # are there any not handled interrupts? def interrupt? !@interrupts.empty? end # push an interrupt to the stack. this interrupt is considered not handled. def push_interrupt(e) @interrupts.push(e) end # pop an interrupt from the stack def pop_interrupt @interrupts.pop end def handle_error(e, line_number = nil) e = internal_error unless e.is_a?(Liquid::Error) e.template_name ||= template_name e.line_number ||= line_number errors.push(e) exception_renderer.call(e).to_s end def invoke(method, *args) strainer.invoke(method, *args).to_liquid end # Push new local scope on the stack. use Context#stack instead def push(new_scope = {}) @scopes.unshift(new_scope) check_overflow end # Merge a hash of variables in the current local scope def merge(new_scopes) @scopes[0].merge!(new_scopes) end # Pop from the stack. use Context#stack instead def pop raise ContextError if @scopes.size == 1 @scopes.shift end # Pushes a new local scope on the stack, pops it at the end of the block # # Example: # context.stack do # context['var'] = 'hi' # end # # context['var'] #=> nil def stack(new_scope = {}) push(new_scope) yield ensure pop end # Creates a new context inheriting resource limits, filters, environment etc., # but with an isolated scope. def new_isolated_subcontext check_overflow self.class.build( environment: @environment, resource_limits: resource_limits, static_environments: static_environments, registers: Registers.new(registers), ).tap do |subcontext| subcontext.base_scope_depth = base_scope_depth + 1 subcontext.exception_renderer = exception_renderer subcontext.filters = @filters subcontext.strainer = nil subcontext.errors = errors subcontext.warnings = warnings subcontext.disabled_tags = @disabled_tags end end def clear_instance_assigns @scopes[0] = {} end # Only allow String, Numeric, Hash, Array, Proc, Boolean or Liquid::Drop def []=(key, value) @scopes[0][key] = value end # Look up variable, either resolve directly after considering the name. We can directly handle # Strings, digits, floats and booleans (true,false). # If no match is made we lookup the variable in the current scope and # later move up to the parent blocks to see if we can resolve the variable somewhere up the tree. # Some special keywords return symbols. Those symbols are to be called on the rhs object in expressions # # Example: # products == empty #=> products.empty? def [](expression) evaluate(Expression.parse(expression, @string_scanner)) end def key?(key) find_variable(key, raise_on_not_found: false) != nil end def evaluate(object) object.respond_to?(:evaluate) ? object.evaluate(self) : object end # Fetches an object starting at the local scope and then moving up the hierachy def find_variable(key, raise_on_not_found: true) # This was changed from find() to find_index() because this is a very hot # path and find_index() is optimized in MRI to reduce object allocation index = @scopes.find_index { |s| s.key?(key) } variable = if index lookup_and_evaluate(@scopes[index], key, raise_on_not_found: raise_on_not_found) else try_variable_find_in_environments(key, raise_on_not_found: raise_on_not_found) end # update variable's context before invoking #to_liquid variable.context = self if variable.respond_to?(:context=) liquid_variable = variable.to_liquid liquid_variable.context = self if variable != liquid_variable && liquid_variable.respond_to?(:context=) liquid_variable end def lookup_and_evaluate(obj, key, raise_on_not_found: true) if @strict_variables && raise_on_not_found && obj.respond_to?(:key?) && !obj.key?(key) raise Liquid::UndefinedVariable, "undefined variable #{key}" end value = obj[key] if value.is_a?(Proc) && obj.respond_to?(:[]=) obj[key] = value.arity == 0 ? value.call : value.call(self) else value end end def with_disabled_tags(tag_names) tag_names.each do |name| @disabled_tags[name] = @disabled_tags.fetch(name, 0) + 1 end yield ensure tag_names.each do |name| @disabled_tags[name] -= 1 end end def tag_disabled?(tag_name) @disabled_tags.fetch(tag_name, 0) > 0 end protected attr_writer :base_scope_depth, :warnings, :errors, :strainer, :filters, :disabled_tags private attr_reader :base_scope_depth def try_variable_find_in_environments(key, raise_on_not_found:) @environments.each do |environment| found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found) if !found_variable.nil? || @strict_variables && raise_on_not_found return found_variable end end @static_environments.each do |environment| found_variable = lookup_and_evaluate(environment, key, raise_on_not_found: raise_on_not_found) if !found_variable.nil? || @strict_variables && raise_on_not_found return found_variable end end nil end def check_overflow raise StackLevelError, "Nesting too deep" if overflow? end def overflow? base_scope_depth + @scopes.length > Block::MAX_DEPTH end def internal_error # raise and catch to set backtrace and cause on exception raise Liquid::InternalError, 'internal' rescue Liquid::InternalError => exc exc end def squash_instance_assigns_with_environments @scopes.last.each_key do |k| @environments.each do |env| if env.key?(k) scopes.last[k] = lookup_and_evaluate(env, k) break end end end end # squash_instance_assigns_with_environments end # Context end # Liquid ================================================ FILE: lib/liquid/deprecations.rb ================================================ # frozen_string_literal: true require "set" module Liquid class Deprecations class << self attr_accessor :warned Deprecations.warned = Set.new def warn(name, alternative) return if warned.include?(name) warned << name caller_location = caller_locations(2, 1).first Warning.warn("[DEPRECATION] #{name} is deprecated. Use #{alternative} instead. Called from #{caller_location}\n") end end end end ================================================ FILE: lib/liquid/document.rb ================================================ # frozen_string_literal: true module Liquid class Document def self.parse(tokens, parse_context) doc = new(parse_context) doc.parse(tokens, parse_context) doc end attr_reader :parse_context, :body def initialize(parse_context) @parse_context = parse_context @body = new_body end def nodelist @body.nodelist end def parse(tokenizer, parse_context) while parse_body(tokenizer) end @body.freeze rescue SyntaxError => e e.line_number ||= parse_context.line_number raise end def unknown_tag(tag, _markup, _tokenizer) case tag when 'else', 'end' raise SyntaxError, parse_context.locale.t("errors.syntax.unexpected_outer_tag", tag: tag) else raise SyntaxError, parse_context.locale.t("errors.syntax.unknown_tag", tag: tag) end end def render_to_output_buffer(context, output) @body.render_to_output_buffer(context, output) end def render(context) render_to_output_buffer(context, +'') end private def new_body parse_context.new_block_body end def parse_body(tokenizer) @body.parse(tokenizer, parse_context) do |unknown_tag_name, unknown_tag_markup| if unknown_tag_name unknown_tag(unknown_tag_name, unknown_tag_markup, tokenizer) true else false end end end end end ================================================ FILE: lib/liquid/drop.rb ================================================ # frozen_string_literal: true require 'set' module Liquid # A drop in liquid is a class which allows you to export DOM like things to liquid. # Methods of drops are callable. # The main use for liquid drops is to implement lazy loaded objects. # If you would like to make data available to the web designers which you don't want loaded unless needed then # a drop is a great way to do that. # # Example: # # class ProductDrop < Liquid::Drop # def top_sales # Shop.current.products.find(:all, :order => 'sales', :limit => 10 ) # end # end # # tmpl = Liquid::Template.parse( ' {% for product in product.top_sales %} {{ product.name }} {%endfor%} ' ) # tmpl.render('product' => ProductDrop.new ) # will invoke top_sales query. # # Your drop can either implement the methods sans any parameters # or implement the liquid_method_missing(name) method which is a catch all. class Drop attr_writer :context def initialize @context = nil end # Catch all for the method def liquid_method_missing(method) return unless @context&.strict_variables raise Liquid::UndefinedDropMethod, "undefined method #{method}" end # called by liquid to invoke a drop def invoke_drop(method_or_key) if self.class.invokable?(method_or_key) send(method_or_key) else liquid_method_missing(method_or_key) end end def key?(_name) true end def inspect self.class.to_s end def to_liquid self end def to_s self.class.name end alias_method :[], :invoke_drop # Check for method existence without invoking respond_to?, which creates symbols def self.invokable?(method_name) invokable_methods.include?(method_name.to_s) end def self.invokable_methods @invokable_methods ||= begin blacklist = Liquid::Drop.public_instance_methods + [:each] if include?(Enumerable) blacklist += Enumerable.public_instance_methods blacklist -= [:sort, :count, :first, :min, :max] end whitelist = [:to_liquid] + (public_instance_methods - blacklist) Set.new(whitelist.map(&:to_s)) end end end end ================================================ FILE: lib/liquid/environment.rb ================================================ # frozen_string_literal: true module Liquid # The Environment is the container for all configuration options of Liquid, such as # the registered tags, filters, and the default error mode. class Environment # The default error mode for all templates. This can be overridden on a # per-template basis. attr_accessor :error_mode # The tags that are available to use in the template. attr_accessor :tags # The strainer template which is used to store filters that are available to # use in templates. attr_accessor :strainer_template # The exception renderer that is used to render exceptions that are raised # when rendering a template attr_accessor :exception_renderer # The default file system that is used to load templates from. attr_accessor :file_system # The default resource limits that are used to limit the resources that a # template can consume. attr_accessor :default_resource_limits class << self # Creates a new environment instance. # # @param tags [Hash] The tags that are available to use in # the template. # @param file_system The default file system that is used # to load templates from. # @param error_mode [Symbol] The default error mode for all templates # (either :strict2, :strict, :warn, or :lax). # @param exception_renderer [Proc] The exception renderer that is used to # render exceptions. # @yieldparam environment [Environment] The environment instance that is being built. # @return [Environment] The new environment instance. def build(tags: nil, file_system: nil, error_mode: nil, exception_renderer: nil) ret = new ret.tags = tags if tags ret.file_system = file_system if file_system ret.error_mode = error_mode if error_mode ret.exception_renderer = exception_renderer if exception_renderer yield ret if block_given? ret.freeze end # Returns the default environment instance. # # @return [Environment] The default environment instance. def default @default ||= new end # Sets the default environment instance for the duration of the block # # @param environment [Environment] The environment instance to use as the default for the # duration of the block. # @yield # @return [Object] The return value of the block. def dangerously_override(environment) original_default = @default @default = environment yield ensure @default = original_default end end # Initializes a new environment instance. # @api private def initialize @tags = Tags::STANDARD_TAGS.dup @error_mode = :lax @strainer_template = Class.new(StrainerTemplate).tap do |klass| klass.add_filter(StandardFilters) end @exception_renderer = ->(exception) { exception } @file_system = BlankFileSystem.new @default_resource_limits = Const::EMPTY_HASH @strainer_template_class_cache = {} end # Registers a new tag with the environment. # # @param name [String] The name of the tag. # @param klass [Liquid::Tag] The class that implements the tag. # @return [void] def register_tag(name, klass) @tags[name] = klass end # Registers a new filter with the environment. # # @param filter [Module] The module that contains the filter methods. # @return [void] def register_filter(filter) @strainer_template_class_cache.clear @strainer_template.add_filter(filter) end # Registers multiple filters with this environment. # # @param filters [Array] The modules that contain the filter methods. # @return [self] def register_filters(filters) @strainer_template_class_cache.clear filters.each { |f| @strainer_template.add_filter(f) } self end # Creates a new strainer instance with the given filters, caching the result # for faster lookup. # # @param context [Liquid::Context] The context that the strainer will be # used in. # @param filters [Array] The filters that the strainer will have # access to. # @return [Liquid::Strainer] The new strainer instance. def create_strainer(context, filters = Const::EMPTY_ARRAY) return @strainer_template.new(context) if filters.empty? strainer_template = @strainer_template_class_cache[filters] ||= begin klass = Class.new(@strainer_template) filters.each { |f| klass.add_filter(f) } klass end strainer_template.new(context) end # Returns the names of all the filter methods that are available to use in # the strainer template. # # @return [Array] The names of all the filter methods. def filter_method_names @strainer_template.filter_method_names end # Returns the tag class for the given tag name. # # @param name [String] The name of the tag. # @return [Liquid::Tag] The tag class. def tag_for_name(name) @tags[name] end def freeze @tags.freeze # TODO: freeze the tags, currently this is not possible because of liquid-c # @strainer_template.freeze super end end end ================================================ FILE: lib/liquid/errors.rb ================================================ # frozen_string_literal: true module Liquid class Error < ::StandardError attr_accessor :line_number attr_accessor :template_name attr_accessor :markup_context def to_s(with_prefix = true) str = +"" str << message_prefix if with_prefix str << super() if markup_context str << " " str << markup_context end str end private def message_prefix str = +"" str << if is_a?(SyntaxError) "Liquid syntax error" else "Liquid error" end if line_number str << " (" str << template_name << " " if template_name str << "line " << line_number.to_s << ")" end str << ": " str end end ArgumentError = Class.new(Error) ContextError = Class.new(Error) FileSystemError = Class.new(Error) StandardError = Class.new(Error) SyntaxError = Class.new(Error) StackLevelError = Class.new(Error) MemoryError = Class.new(Error) ZeroDivisionError = Class.new(Error) FloatDomainError = Class.new(Error) UndefinedVariable = Class.new(Error) UndefinedDropMethod = Class.new(Error) UndefinedFilter = Class.new(Error) MethodOverrideError = Class.new(Error) DisabledError = Class.new(Error) InternalError = Class.new(Error) TemplateEncodingError = Class.new(Error) end ================================================ FILE: lib/liquid/expression.rb ================================================ # frozen_string_literal: true module Liquid class Expression LITERALS = { nil => nil, 'nil' => nil, 'null' => nil, '' => nil, 'true' => true, 'false' => false, 'blank' => '', 'empty' => '', # in lax mode, minus sign can be a VariableLookup # For simplicity and performace, we treat it like a literal '-' => VariableLookup.parse("-", nil).freeze, }.freeze DOT = ".".ord ZERO = "0".ord NINE = "9".ord DASH = "-".ord # Use an atomic group (?>...) to avoid pathological backtracing from # malicious input as described in https://github.com/Shopify/liquid/issues/1357 RANGES_REGEX = /\A\(\s*(?>(\S+)\s*\.\.)\s*(\S+)\s*\)\z/ INTEGER_REGEX = /\A(-?\d+)\z/ FLOAT_REGEX = /\A(-?\d+)\.\d+\z/ class << self def safe_parse(parser, ss = StringScanner.new(""), cache = nil) parse(parser.expression, ss, cache) end def parse(markup, ss = StringScanner.new(""), cache = nil) return unless markup markup = markup.strip # markup can be a frozen string if (markup.start_with?('"') && markup.end_with?('"')) || (markup.start_with?("'") && markup.end_with?("'")) return markup[1..-2] elsif LITERALS.key?(markup) return LITERALS[markup] end # Cache only exists during parsing if cache return cache[markup] if cache.key?(markup) cache[markup] = inner_parse(markup, ss, cache).freeze else inner_parse(markup, ss, nil).freeze end end def inner_parse(markup, ss, cache) if markup.start_with?("(") && markup.end_with?(")") && markup =~ RANGES_REGEX return RangeLookup.parse( Regexp.last_match(1), Regexp.last_match(2), ss, cache, ) end if (num = parse_number(markup, ss)) num else VariableLookup.parse(markup, ss, cache) end end def parse_number(markup, ss) # check if the markup is simple integer or float case markup when INTEGER_REGEX return Integer(markup, 10) when FLOAT_REGEX return markup.to_f end ss.string = markup # the first byte must be a digit or a dash byte = ss.scan_byte return false if byte != DASH && (byte < ZERO || byte > NINE) if byte == DASH peek_byte = ss.peek_byte # if it starts with a dash, the next byte must be a digit return false if peek_byte.nil? || !(peek_byte >= ZERO && peek_byte <= NINE) end # The markup could be a float with multiple dots first_dot_pos = nil num_end_pos = nil while (byte = ss.scan_byte) return false if byte != DOT && (byte < ZERO || byte > NINE) # we found our number and now we are just scanning the rest of the string next if num_end_pos if byte == DOT if first_dot_pos.nil? first_dot_pos = ss.pos else # we found another dot, so we know that the number ends here num_end_pos = ss.pos - 1 end end end num_end_pos = markup.length if ss.eos? if num_end_pos # number ends with a number "123.123" markup.byteslice(0, num_end_pos).to_f else # number ends with a dot "123." markup.byteslice(0, first_dot_pos).to_f end end end end end ================================================ FILE: lib/liquid/extensions.rb ================================================ # frozen_string_literal: true require 'time' require 'date' class String # :nodoc: def to_liquid self end end class Symbol # :nodoc: def to_liquid to_s end end class Array # :nodoc: def to_liquid self end end class Hash # :nodoc: def to_liquid self end end class Numeric # :nodoc: def to_liquid self end end class Range # :nodoc: def to_liquid self end end class Time # :nodoc: def to_liquid self end end class DateTime < Date # :nodoc: def to_liquid self end end class Date # :nodoc: def to_liquid self end end class TrueClass def to_liquid # :nodoc: self end end class FalseClass def to_liquid # :nodoc: self end end class NilClass def to_liquid # :nodoc: self end end ================================================ FILE: lib/liquid/file_system.rb ================================================ # frozen_string_literal: true module Liquid # A Liquid file system is a way to let your templates retrieve other templates for use with the include tag. # # You can implement subclasses that retrieve templates from the database, from the file system using a different # path structure, you can provide them as hard-coded inline strings, or any manner that you see fit. # # You can add additional instance variables, arguments, or methods as needed. # # Example: # # Liquid::Template.file_system = Liquid::LocalFileSystem.new(template_path) # liquid = Liquid::Template.parse(template) # # This will parse the template with a LocalFileSystem implementation rooted at 'template_path'. class BlankFileSystem # Called by Liquid to retrieve a template file def read_template_file(_template_path) raise FileSystemError, "This liquid context does not allow includes." end end # This implements an abstract file system which retrieves template files named in a manner similar to Rails partials, # ie. with the template name prefixed with an underscore. The extension ".liquid" is also added. # # For security reasons, template paths are only allowed to contain letters, numbers, and underscore. # # Example: # # file_system = Liquid::LocalFileSystem.new("/some/path") # # file_system.full_path("mypartial") # => "/some/path/_mypartial.liquid" # file_system.full_path("dir/mypartial") # => "/some/path/dir/_mypartial.liquid" # # Optionally in the second argument you can specify a custom pattern for template filenames. # The Kernel::sprintf format specification is used. # Default pattern is "_%s.liquid". # # Example: # # file_system = Liquid::LocalFileSystem.new("/some/path", "%s.html") # # file_system.full_path("index") # => "/some/path/index.html" # class LocalFileSystem attr_accessor :root def initialize(root, pattern = "_%s.liquid") @root = root @pattern = pattern end def read_template_file(template_path) full_path = full_path(template_path) raise FileSystemError, "No such template '#{template_path}'" unless File.exist?(full_path) File.read(full_path) end def full_path(template_path) raise FileSystemError, "Illegal template name '#{template_path}'" unless %r{\A[^./][a-zA-Z0-9_/]+\z}.match?(template_path) full_path = if template_path.include?('/') File.join(root, File.dirname(template_path), @pattern % File.basename(template_path)) else File.join(root, @pattern % template_path) end raise FileSystemError, "Illegal template path '#{File.expand_path(full_path)}'" unless File.expand_path(full_path).start_with?(File.expand_path(root)) full_path end end end ================================================ FILE: lib/liquid/forloop_drop.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type object # @liquid_name forloop # @liquid_summary # Information about a parent [`for` loop](/docs/api/liquid/tags/for). class ForloopDrop < Drop def initialize(name, length, parentloop) @name = name @length = length @parentloop = parentloop @index = 0 end # @liquid_public_docs # @liquid_name length # @liquid_summary # The total number of iterations in the loop. # @liquid_return [number] attr_reader :length # @liquid_public_docs # @liquid_name parentloop # @liquid_summary # The parent `forloop` object. # @liquid_description # If the current `for` loop isn't nested inside another `for` loop, then `nil` is returned. # @liquid_return [forloop] attr_reader :parentloop attr_reader :name # @liquid_public_docs # @liquid_summary # The 1-based index of the current iteration. # @liquid_return [number] def index @index + 1 end # @liquid_public_docs # @liquid_summary # The 0-based index of the current iteration. # @liquid_return [number] def index0 @index end # @liquid_public_docs # @liquid_summary # The 1-based index of the current iteration, in reverse order. # @liquid_return [number] def rindex @length - @index end # @liquid_public_docs # @liquid_summary # The 0-based index of the current iteration, in reverse order. # @liquid_return [number] def rindex0 @length - @index - 1 end # @liquid_public_docs # @liquid_summary # Returns `true` if the current iteration is the first. Returns `false` if not. # @liquid_return [boolean] def first @index == 0 end # @liquid_public_docs # @liquid_summary # Returns `true` if the current iteration is the last. Returns `false` if not. # @liquid_return [boolean] def last @index == @length - 1 end protected def increment! @index += 1 end end end ================================================ FILE: lib/liquid/i18n.rb ================================================ # frozen_string_literal: true require 'yaml' module Liquid class I18n DEFAULT_LOCALE = File.join(File.expand_path(__dir__), "locales", "en.yml") TranslationError = Class.new(StandardError) attr_reader :path def initialize(path = DEFAULT_LOCALE) @path = path end def translate(name, vars = {}) interpolate(deep_fetch_translation(name), vars) end alias_method :t, :translate def locale @locale ||= YAML.load_file(@path) end private def interpolate(name, vars) name.gsub(/%\{(\w+)\}/) do # raise TranslationError, "Undefined key #{$1} for interpolation in translation #{name}" unless vars[$1.to_sym] vars[Regexp.last_match(1).to_sym].to_s end end def deep_fetch_translation(name) name.split('.').reduce(locale) do |level, cur| level[cur] || raise(TranslationError, "Translation for #{name} does not exist in locale #{path}") end end end end ================================================ FILE: lib/liquid/interrupts.rb ================================================ # frozen_string_literal: true module Liquid # An interrupt is any command that breaks processing of a block (ex: a for loop). class Interrupt attr_reader :message def initialize(message = nil) @message = message || "interrupt" end end # Interrupt that is thrown whenever a {% break %} is called. class BreakInterrupt < Interrupt; end # Interrupt that is thrown whenever a {% continue %} is called. class ContinueInterrupt < Interrupt; end end ================================================ FILE: lib/liquid/lexer.rb ================================================ # frozen_string_literal: true module Liquid class Lexer CLOSE_ROUND = [:close_round, ")"].freeze CLOSE_SQUARE = [:close_square, "]"].freeze COLON = [:colon, ":"].freeze COMMA = [:comma, ","].freeze COMPARISION_NOT_EQUAL = [:comparison, "!="].freeze COMPARISON_CONTAINS = [:comparison, "contains"].freeze COMPARISON_EQUAL = [:comparison, "=="].freeze COMPARISON_GREATER_THAN = [:comparison, ">"].freeze COMPARISON_GREATER_THAN_OR_EQUAL = [:comparison, ">="].freeze COMPARISON_LESS_THAN = [:comparison, "<"].freeze COMPARISON_LESS_THAN_OR_EQUAL = [:comparison, "<="].freeze COMPARISON_NOT_EQUAL_ALT = [:comparison, "<>"].freeze DASH = [:dash, "-"].freeze DOT = [:dot, "."].freeze DOTDOT = [:dotdot, ".."].freeze DOT_ORD = ".".ord DOUBLE_STRING_LITERAL = /"[^\"]*"/ EOS = [:end_of_string].freeze IDENTIFIER = /[a-zA-Z_][\w-]*\??/ NUMBER_LITERAL = /-?\d+(\.\d+)?/ OPEN_ROUND = [:open_round, "("].freeze OPEN_SQUARE = [:open_square, "["].freeze PIPE = [:pipe, "|"].freeze QUESTION = [:question, "?"].freeze RUBY_WHITESPACE = [" ", "\t", "\r", "\n", "\f"].freeze SINGLE_STRING_LITERAL = /'[^\']*'/ WHITESPACE_OR_NOTHING = /\s*/ SINGLE_COMPARISON_TOKENS = [].tap do |table| table["<".ord] = COMPARISON_LESS_THAN table[">".ord] = COMPARISON_GREATER_THAN table.freeze end TWO_CHARS_COMPARISON_JUMP_TABLE = [].tap do |table| table["=".ord] = [].tap do |sub_table| sub_table["=".ord] = COMPARISON_EQUAL sub_table.freeze end table["!".ord] = [].tap do |sub_table| sub_table["=".ord] = COMPARISION_NOT_EQUAL sub_table.freeze end table.freeze end COMPARISON_JUMP_TABLE = [].tap do |table| table["<".ord] = [].tap do |sub_table| sub_table["=".ord] = COMPARISON_LESS_THAN_OR_EQUAL sub_table[">".ord] = COMPARISON_NOT_EQUAL_ALT sub_table.freeze end table[">".ord] = [].tap do |sub_table| sub_table["=".ord] = COMPARISON_GREATER_THAN_OR_EQUAL sub_table.freeze end table.freeze end NEXT_MATCHER_JUMP_TABLE = [].tap do |table| "a".upto("z") do |c| table[c.ord] = [:id, IDENTIFIER].freeze table[c.upcase.ord] = [:id, IDENTIFIER].freeze end table["_".ord] = [:id, IDENTIFIER].freeze "0".upto("9") do |c| table[c.ord] = [:number, NUMBER_LITERAL].freeze end table["-".ord] = [:number, NUMBER_LITERAL].freeze table["'".ord] = [:string, SINGLE_STRING_LITERAL].freeze table["\"".ord] = [:string, DOUBLE_STRING_LITERAL].freeze table.freeze end SPECIAL_TABLE = [].tap do |table| table["|".ord] = PIPE table[".".ord] = DOT table[":".ord] = COLON table[",".ord] = COMMA table["[".ord] = OPEN_SQUARE table["]".ord] = CLOSE_SQUARE table["(".ord] = OPEN_ROUND table[")".ord] = CLOSE_ROUND table["?".ord] = QUESTION table["-".ord] = DASH end NUMBER_TABLE = [].tap do |table| "0".upto("9") do |c| table[c.ord] = true end table.freeze end # rubocop:disable Metrics/BlockNesting class << self def tokenize(ss) output = [] until ss.eos? ss.skip(WHITESPACE_OR_NOTHING) break if ss.eos? start_pos = ss.pos peeked = ss.peek_byte if (special = SPECIAL_TABLE[peeked]) ss.scan_byte # Special case for ".." if special == DOT && ss.peek_byte == DOT_ORD ss.scan_byte output << DOTDOT elsif special == DASH # Special case for negative numbers if (peeked_byte = ss.peek_byte) && NUMBER_TABLE[peeked_byte] ss.pos -= 1 output << [:number, ss.scan(NUMBER_LITERAL)] else output << special end else output << special end elsif (sub_table = TWO_CHARS_COMPARISON_JUMP_TABLE[peeked]) ss.scan_byte if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte]) output << found ss.scan_byte else raise_syntax_error(start_pos, ss) end elsif (sub_table = COMPARISON_JUMP_TABLE[peeked]) ss.scan_byte if (peeked_byte = ss.peek_byte) && (found = sub_table[peeked_byte]) output << found ss.scan_byte else output << SINGLE_COMPARISON_TOKENS[peeked] end else type, pattern = NEXT_MATCHER_JUMP_TABLE[peeked] if type && (t = ss.scan(pattern)) # Special case for "contains" output << if type == :id && t == "contains" && output.last&.first != :dot COMPARISON_CONTAINS else [type, t] end else raise_syntax_error(start_pos, ss) end end end # rubocop:enable Metrics/BlockNesting output << EOS rescue ::ArgumentError => e if e.message == "invalid byte sequence in #{ss.string.encoding}" raise SyntaxError, "Invalid byte sequence in #{ss.string.encoding}" else raise end end def raise_syntax_error(start_pos, ss) ss.pos = start_pos # the character could be a UTF-8 character, use getch to get all the bytes raise SyntaxError, "Unexpected character #{ss.getch}" end end end end ================================================ FILE: lib/liquid/locales/en.yml ================================================ --- errors: syntax: tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: %{tag}" block_tag_unexpected_args: "Syntax Error in '%{tag}' - Valid syntax: {% %{tag} %}{% end%{tag} %}" assign: "Syntax Error in 'assign' - Valid syntax: assign [var] = [source]" capture: "Syntax Error in 'capture' - Valid syntax: capture [var]" case: "Syntax Error in 'case' - Valid syntax: case [condition]" case_invalid_when: "Syntax Error in tag 'case' - Valid when condition: {% when [condition] [or condition2...] %}" case_invalid_else: "Syntax Error in tag 'case' - Valid else condition: {% else %} (no parameters) " cycle: "Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]" doc_invalid_nested: "Syntax Error in 'doc' - Nested doc tags are not allowed" for: "Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]" for_invalid_in: "For loops require an 'in' clause" for_invalid_attribute: "Invalid attribute in for loop. Valid attributes are limit and offset" if: "Syntax Error in tag 'if' - Valid syntax: if [expression]" include: "Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]" inline_comment_invalid: "Syntax error in tag '#' - Each line of comments must be prefixed by the '#' character" invalid_delimiter: "'%{tag}' is not a valid delimiter for %{block_name} tags. use %{block_delimiter}" invalid_template_encoding: "Invalid template encoding" render: "Syntax error in tag 'render' - Template name must be a quoted string" table_row: "Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols=3" table_row_invalid_attribute: "Invalid attribute '%{attribute}' in tablerow loop. Valid attributes are cols, limit, offset, and range" tag_never_closed: "'%{block_name}' tag was never closed" tag_termination: "Tag '%{token}' was not properly terminated with regexp: %{tag_end}" unexpected_else: "%{block_name} tag does not expect 'else' tag" unexpected_outer_tag: "Unexpected outer '%{tag}' tag" unknown_tag: "Unknown tag '%{tag}'" variable_termination: "Variable '%{token}' was not properly terminated with regexp: %{tag_end}" argument: include: "Argument error in tag 'include' - Illegal template name" disabled: tag: "usage is not allowed in this context" ================================================ FILE: lib/liquid/parse_context.rb ================================================ # frozen_string_literal: true module Liquid class ParseContext attr_accessor :locale, :line_number, :trim_whitespace, :depth attr_reader :partial, :warnings, :error_mode, :environment def initialize(options = Const::EMPTY_HASH) @environment = options.fetch(:environment, Environment.default) @template_options = options ? options.dup : {} @locale = @template_options[:locale] ||= I18n.new @warnings = [] # constructing new StringScanner in Lexer, Tokenizer, etc is expensive # This StringScanner will be shared by all of them @string_scanner = StringScanner.new("") @expression_cache = if options[:expression_cache].nil? {} elsif options[:expression_cache].respond_to?(:[]) && options[:expression_cache].respond_to?(:[]=) options[:expression_cache] elsif options[:expression_cache] {} end self.depth = 0 self.partial = false end def [](option_key) @options[option_key] end def new_block_body Liquid::BlockBody.new end def new_parser(input) @string_scanner.string = input Parser.new(@string_scanner) end def new_tokenizer(source, start_line_number: nil, for_liquid_tag: false) Tokenizer.new( source: source, string_scanner: @string_scanner, line_number: start_line_number, for_liquid_tag: for_liquid_tag, ) end def safe_parse_expression(parser) Expression.safe_parse(parser, @string_scanner, @expression_cache) end def parse_expression(markup, safe: false) if !safe && @error_mode == :strict2 # parse_expression is a widely used API. To maintain backward # compatibility while raising awareness about strict2 parser standards, # the safe flag supports API users make a deliberate decision. # # In strict2 mode, markup MUST come from a string returned by the parser # (e.g., parser.expression). We're not calling the parser here to # prevent redundant parser overhead. raise Liquid::InternalError, "unsafe parse_expression cannot be used in strict2 mode" end Expression.parse(markup, @string_scanner, @expression_cache) end def partial=(value) @partial = value @options = value ? partial_options : @template_options @error_mode = @options[:error_mode] || @environment.error_mode end def partial_options @partial_options ||= begin dont_pass = @template_options[:include_options_blacklist] if dont_pass == true { locale: locale } elsif dont_pass.is_a?(Array) @template_options.reject { |k, _v| dont_pass.include?(k) } else @template_options end end end end end ================================================ FILE: lib/liquid/parse_tree_visitor.rb ================================================ # frozen_string_literal: true module Liquid class ParseTreeVisitor def self.for(node, callbacks = Hash.new(proc {})) if defined?(node.class::ParseTreeVisitor) node.class::ParseTreeVisitor else self end.new(node, callbacks) end def initialize(node, callbacks) @node = node @callbacks = callbacks end def add_callback_for(*classes, &block) callback = block callback = ->(node, _) { yield node } if block.arity.abs == 1 callback = ->(_, _) { yield } if block.arity.zero? classes.each { |klass| @callbacks[klass] = callback } self end def visit(context = nil) children.map do |node| item, new_context = @callbacks[node.class].call(node, context) [ item, ParseTreeVisitor.for(node, @callbacks).visit(new_context || context), ] end end protected def children @node.respond_to?(:nodelist) ? Array(@node.nodelist) : Const::EMPTY_ARRAY end end end ================================================ FILE: lib/liquid/parser.rb ================================================ # frozen_string_literal: true module Liquid class Parser def initialize(input) ss = input.is_a?(StringScanner) ? input : StringScanner.new(input) @tokens = Lexer.tokenize(ss) @p = 0 # pointer to current location end def jump(point) @p = point end def consume(type = nil) token = @tokens[@p] if type && token[0] != type raise SyntaxError, "Expected #{type} but found #{@tokens[@p].first}" end @p += 1 token[1] end # Only consumes the token if it matches the type # Returns the token's contents if it was consumed # or false otherwise. def consume?(type) token = @tokens[@p] return false unless token && token[0] == type @p += 1 token[1] end # Like consume? Except for an :id token of a certain name def id?(str) token = @tokens[@p] return false unless token && token[0] == :id return false unless token[1] == str @p += 1 token[1] end def look(type, ahead = 0) tok = @tokens[@p + ahead] return false unless tok tok[0] == type end def expression token = @tokens[@p] case token[0] when :id str = consume str << variable_lookups when :open_square str = consume.dup str << expression str << consume(:close_square) str << variable_lookups when :string, :number consume when :open_round consume first = expression consume(:dotdot) last = expression consume(:close_round) "(#{first}..#{last})" else raise SyntaxError, "#{token} is not a valid expression" end end def argument str = +"" # might be a keyword argument (identifier: expression) if look(:id) && look(:colon, 1) str << consume << consume << ' ' end str << expression str end def variable_lookups str = +"" loop do if look(:open_square) str << consume str << expression str << consume(:close_square) elsif look(:dot) str << consume str << consume(:id) else break end end str end end end ================================================ FILE: lib/liquid/parser_switching.rb ================================================ # frozen_string_literal: true module Liquid module ParserSwitching # Do not use this. # # It's basically doing the same thing the {#parse_with_selected_parser}, # except this will try the strict parser regardless of the error mode, # and fall back to the lax parser if the error mode is lax or warn, # except when in strict2 mode where it uses the strict2 parser. # # @deprecated Use {#parse_with_selected_parser} instead. def strict_parse_with_error_mode_fallback(markup) return strict2_parse_with_error_context(markup) if strict2_mode? strict_parse_with_error_context(markup) rescue SyntaxError => e case parse_context.error_mode when :rigid rigid_warn raise when :strict2 raise when :strict raise when :warn parse_context.warnings << e end lax_parse(markup) end def parse_with_selected_parser(markup) case parse_context.error_mode when :rigid then rigid_warn && strict2_parse_with_error_context(markup) when :strict2 then strict2_parse_with_error_context(markup) when :strict then strict_parse_with_error_context(markup) when :lax then lax_parse(markup) when :warn begin strict2_parse_with_error_context(markup) rescue SyntaxError => e parse_context.warnings << e lax_parse(markup) end end end def strict2_mode? parse_context.error_mode == :strict2 || parse_context.error_mode == :rigid end private def rigid_warn Deprecations.warn(':rigid', ':strict2') end def strict2_parse_with_error_context(markup) strict2_parse(markup) rescue SyntaxError => e e.line_number = line_number e.markup_context = markup_context(markup) raise e end def strict_parse_with_error_context(markup) strict_parse(markup) rescue SyntaxError => e e.line_number = line_number e.markup_context = markup_context(markup) raise e end def markup_context(markup) "in \"#{markup.strip}\"" end end end ================================================ FILE: lib/liquid/partial_cache.rb ================================================ # frozen_string_literal: true module Liquid class PartialCache def self.load(template_name, context:, parse_context:) cached_partials = context.registers[:cached_partials] cache_key = "#{template_name}:#{parse_context.error_mode}" cached = cached_partials[cache_key] return cached if cached file_system = context.registers[:file_system] source = file_system.read_template_file(template_name) parse_context.partial = true template_factory = context.registers[:template_factory] template = template_factory.for(template_name) begin partial = template.parse(source, parse_context) rescue Liquid::Error => e e.template_name = template&.name || template_name raise e end partial.name ||= template_name cached_partials[cache_key] = partial ensure parse_context.partial = false end end end ================================================ FILE: lib/liquid/profiler/hooks.rb ================================================ # frozen_string_literal: true module Liquid module BlockBodyProfilingHook def render_node(context, output, node) if (profiler = context.profiler) profiler.profile_node(context.template_name, code: node.raw, line_number: node.line_number) do super end else super end end end BlockBody.prepend(BlockBodyProfilingHook) module DocumentProfilingHook def render_to_output_buffer(context, output) return super unless context.profiler context.profiler.profile(context.template_name) { super } end end Document.prepend(DocumentProfilingHook) module ContextProfilingHook attr_accessor :profiler def new_isolated_subcontext new_context = super new_context.profiler = profiler new_context end end Context.prepend(ContextProfilingHook) end ================================================ FILE: lib/liquid/profiler.rb ================================================ # frozen_string_literal: true require 'liquid/profiler/hooks' module Liquid # Profiler enables support for profiling template rendering to help track down performance issues. # # To enable profiling, first require 'liquid/profiler'. # Then, to profile a parse/render cycle, pass the profile: true option to Liquid::Template.parse. # After Liquid::Template#render is called, the template object makes available an instance of this # class via the Liquid::Template#profiler method. # # template = Liquid::Template.parse(template_content, profile: true) # output = template.render # profile = template.profiler # # This object contains all profiling information, containing information on what tags were rendered, # where in the templates these tags live, and how long each tag took to render. # # This is a tree structure that is Enumerable all the way down, and keeps track of tags and rendering times # inside of {% include %} tags. # # profile.each do |node| # # Access to the node itself # node.code # # # Which template and line number of this node. # # The top-level template name is `nil` by default, but can be set in the Liquid::Context before rendering. # node.partial # node.line_number # # # Render time in seconds of this node # node.render_time # # # If the template used {% include %}, this node will also have children. # node.children.each do |child2| # # ... # end # end # # Profiler also exposes the total time of the template's render in Liquid::Profiler#total_render_time. # # All render times are in seconds. There is a small performance hit when profiling is enabled. # class Profiler include Enumerable class Timing attr_reader :code, :template_name, :line_number, :children attr_accessor :total_time alias_method :render_time, :total_time alias_method :partial, :template_name def initialize(code: nil, template_name: nil, line_number: nil) @code = code @template_name = template_name @line_number = line_number @children = [] end def self_time @self_time ||= begin total_children_time = 0.0 @children.each do |child| total_children_time += child.total_time end @total_time - total_children_time end end end attr_reader :total_time alias_method :total_render_time, :total_time def initialize @root_children = [] @current_children = nil @total_time = 0.0 end def profile(template_name, &block) # nested renders are done from a tag that already has a timing node return yield if @current_children root_children = @root_children render_idx = root_children.length begin @current_children = root_children profile_node(template_name, &block) ensure @current_children = nil if (timing = root_children[render_idx]) @total_time += timing.total_time end end end def children children = @root_children if children.length == 1 children.first.children else children end end def each(&block) children.each(&block) end def [](idx) children[idx] end def length children.length end def profile_node(template_name, code: nil, line_number: nil) timing = Timing.new(code: code, template_name: template_name, line_number: line_number) parent_children = @current_children start_time = monotonic_time begin @current_children = timing.children yield ensure @current_children = parent_children timing.total_time = monotonic_time - start_time parent_children << timing end end private def monotonic_time Process.clock_gettime(Process::CLOCK_MONOTONIC) end end end ================================================ FILE: lib/liquid/range_lookup.rb ================================================ # frozen_string_literal: true module Liquid class RangeLookup def self.parse(start_markup, end_markup, string_scanner, cache = nil) start_obj = Expression.parse(start_markup, string_scanner, cache) end_obj = Expression.parse(end_markup, string_scanner, cache) if start_obj.respond_to?(:evaluate) || end_obj.respond_to?(:evaluate) new(start_obj, end_obj) else begin start_obj.to_i..end_obj.to_i rescue NoMethodError invalid_expr = start_markup unless start_obj.respond_to?(:to_i) invalid_expr ||= end_markup unless end_obj.respond_to?(:to_i) if invalid_expr raise Liquid::SyntaxError, "Invalid expression type '#{invalid_expr}' in range expression" end raise end end end attr_reader :start_obj, :end_obj def initialize(start_obj, end_obj) @start_obj = start_obj @end_obj = end_obj end def evaluate(context) start_int = to_integer(context.evaluate(@start_obj)) end_int = to_integer(context.evaluate(@end_obj)) start_int..end_int end private def to_integer(input) case input when Integer input when NilClass, String input.to_i else Utils.to_integer(input) end end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [@node.start_obj, @node.end_obj] end end end end ================================================ FILE: lib/liquid/registers.rb ================================================ # frozen_string_literal: true module Liquid class Registers attr_reader :static def initialize(registers = {}) @static = registers.is_a?(Registers) ? registers.static : registers @changes = {} end def []=(key, value) @changes[key] = value end def [](key) if @changes.key?(key) @changes[key] else @static[key] end end def delete(key) @changes.delete(key) end UNDEFINED = Object.new def fetch(key, default = UNDEFINED, &block) if @changes.key?(key) @changes.fetch(key) elsif default != UNDEFINED if block_given? @static.fetch(key, &block) else @static.fetch(key, default) end else @static.fetch(key, &block) end end def key?(key) @changes.key?(key) || @static.key?(key) end end # Alias for backwards compatibility StaticRegisters = Registers end ================================================ FILE: lib/liquid/resource_limits.rb ================================================ # frozen_string_literal: true module Liquid class ResourceLimits attr_accessor :render_length_limit, :render_score_limit, :assign_score_limit, :cumulative_render_score_limit, :cumulative_assign_score_limit attr_reader :render_score, :assign_score, :cumulative_render_score, :cumulative_assign_score def initialize(limits) @render_length_limit = limits[:render_length_limit] @render_score_limit = limits[:render_score_limit] @assign_score_limit = limits[:assign_score_limit] @cumulative_render_score_limit = limits[:cumulative_render_score_limit] @cumulative_assign_score_limit = limits[:cumulative_assign_score_limit] @cumulative_render_score = 0 @cumulative_assign_score = 0 reset end def increment_render_score(amount) @render_score += amount @cumulative_render_score += amount raise_limits_reached if @render_score_limit && @render_score > @render_score_limit raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit end def increment_assign_score(amount) @assign_score += amount @cumulative_assign_score += amount raise_limits_reached if @assign_score_limit && @assign_score > @assign_score_limit raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit end # update either render_length or assign_score based on whether or not the writes are captured def increment_write_score(output) if (last_captured = @last_capture_length) captured = output.bytesize increment = captured - last_captured @last_capture_length = captured increment_assign_score(increment) elsif @render_length_limit && output.bytesize > @render_length_limit raise_limits_reached end end def raise_limits_reached @reached_limit = true raise MemoryError, "Memory limits exceeded" end def reached? @reached_limit end def reset @reached_limit = false @last_capture_length = nil @render_score = @assign_score = 0 raise_limits_reached if @cumulative_render_score_limit && @cumulative_render_score > @cumulative_render_score_limit raise_limits_reached if @cumulative_assign_score_limit && @cumulative_assign_score > @cumulative_assign_score_limit end def with_capture old_capture_length = @last_capture_length begin @last_capture_length = 0 yield ensure @last_capture_length = old_capture_length end end end end ================================================ FILE: lib/liquid/standardfilters.rb ================================================ # frozen_string_literal: true require 'cgi' require 'base64' require 'bigdecimal' module Liquid module StandardFilters MAX_I32 = (1 << 31) - 1 private_constant :MAX_I32 MIN_I64 = -(1 << 63) MAX_I64 = (1 << 63) - 1 I64_RANGE = MIN_I64..MAX_I64 private_constant :MIN_I64, :MAX_I64, :I64_RANGE HTML_ESCAPE = { '&' => '&', '>' => '>', '<' => '<', '"' => '"', "'" => ''', }.freeze HTML_ESCAPE_ONCE_REGEXP = /["><']|&(?!([a-zA-Z]+|(#\d+));)/ STRIP_HTML_BLOCKS = Regexp.union( %r{}m, //m, %r{}m, ) STRIP_HTML_TAGS = /<.*?>/m class << self def try_coerce_encoding(input, encoding:) original_encoding = input.encoding if input.encoding != encoding input.force_encoding(encoding) unless input.valid_encoding? input.force_encoding(original_encoding) end end input end end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Returns the size of a string or array. # @liquid_description # The size of a string is the number of characters that the string includes. The size of an array is the number of items # in the array. # @liquid_syntax variable | size # @liquid_return [number] def size(input) input.respond_to?(:size) ? input.size : 0 end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Converts a string to all lowercase characters. # @liquid_syntax string | downcase # @liquid_return [string] def downcase(input) Utils.to_s(input).downcase end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Converts a string to all uppercase characters. # @liquid_syntax string | upcase # @liquid_return [string] def upcase(input) Utils.to_s(input).upcase end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Capitalizes the first word in a string and downcases the remaining characters. # @liquid_syntax string | capitalize # @liquid_return [string] def capitalize(input) Utils.to_s(input).capitalize end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Escapes special characters in HTML, such as `<>`, `'`, and `&`, and converts characters into escape sequences. The filter doesn't effect characters within the string that don’t have a corresponding escape sequence.". # @liquid_syntax string | escape # @liquid_return [string] def escape(input) CGI.escapeHTML(Utils.to_s(input)) unless input.nil? end alias_method :h, :escape # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Escapes a string without changing characters that have already been escaped. # @liquid_syntax string | escape_once # @liquid_return [string] def escape_once(input) Utils.to_s(input).gsub(HTML_ESCAPE_ONCE_REGEXP, HTML_ESCAPE) end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Converts any URL-unsafe characters in a string to the # [percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) equivalent. # @liquid_description # > Note: # > Spaces are converted to a `+` character, instead of a percent-encoded character. # @liquid_syntax string | url_encode # @liquid_return [string] def url_encode(input) CGI.escape(Utils.to_s(input)) unless input.nil? end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Decodes any [percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) characters # in a string. # @liquid_syntax string | url_decode # @liquid_return [string] def url_decode(input) return if input.nil? result = CGI.unescape(Utils.to_s(input)) raise Liquid::ArgumentError, "invalid byte sequence in #{result.encoding}" unless result.valid_encoding? result end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Encodes a string to [Base64 format](https://developer.mozilla.org/en-US/docs/Glossary/Base64). # @liquid_syntax string | base64_encode # @liquid_return [string] def base64_encode(input) Base64.strict_encode64(Utils.to_s(input)) end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Decodes a string in [Base64 format](https://developer.mozilla.org/en-US/docs/Glossary/Base64). # @liquid_syntax string | base64_decode # @liquid_return [string] def base64_decode(input) input = Utils.to_s(input) StandardFilters.try_coerce_encoding(Base64.strict_decode64(input), encoding: input.encoding) rescue ::ArgumentError raise Liquid::ArgumentError, "invalid base64 provided to base64_decode" end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Encodes a string to URL-safe [Base64 format](https://developer.mozilla.org/en-US/docs/Glossary/Base64). # @liquid_syntax string | base64_url_safe_encode # @liquid_return [string] def base64_url_safe_encode(input) Base64.urlsafe_encode64(Utils.to_s(input)) end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Decodes a string in URL-safe [Base64 format](https://developer.mozilla.org/en-US/docs/Glossary/Base64). # @liquid_syntax string | base64_url_safe_decode # @liquid_return [string] def base64_url_safe_decode(input) input = Utils.to_s(input) StandardFilters.try_coerce_encoding(Base64.urlsafe_decode64(input), encoding: input.encoding) rescue ::ArgumentError raise Liquid::ArgumentError, "invalid base64 provided to base64_url_safe_decode" end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Returns a substring or series of array items, starting at a given 0-based index. # @liquid_description # By default, the substring has a length of one character, and the array series has one array item. However, you can # provide a second parameter to specify the number of characters or array items. # @liquid_syntax string | slice # @liquid_return [string] def slice(input, offset, length = nil) offset = Utils.to_integer(offset) length = length ? Utils.to_integer(length) : 1 begin if input.is_a?(Array) input.slice(offset, length) || [] else Utils.to_s(input).slice(offset, length) || '' end rescue RangeError if I64_RANGE.cover?(length) && I64_RANGE.cover?(offset) raise # unexpected error end offset = offset.clamp(I64_RANGE) length = length.clamp(I64_RANGE) retry end end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Truncates a string down to a given number of characters. # @liquid_description # If the specified number of characters is less than the length of the string, then an ellipsis (`...`) is appended to # the truncated string. The ellipsis is included in the character count of the truncated string. # @liquid_syntax string | truncate: number # @liquid_return [string] def truncate(input, length = 50, truncate_string = "...") return if input.nil? input_str = Utils.to_s(input) length = Utils.to_integer(length) truncate_string_str = Utils.to_s(truncate_string) l = length - truncate_string_str.length l = 0 if l < 0 input_str.length > length ? input_str[0...l].concat(truncate_string_str) : input_str end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Truncates a string down to a given number of words. # @liquid_description # If the specified number of words is less than the number of words in the string, then an ellipsis (`...`) is appended to # the truncated string. # # > Caution: # > HTML tags are treated as words, so you should strip any HTML from truncated content. If you don't strip HTML, then # > closing HTML tags can be removed, which can result in unexpected behavior. # @liquid_syntax string | truncatewords: number # @liquid_return [string] def truncatewords(input, words = 15, truncate_string = "...") return if input.nil? input = Utils.to_s(input) words = Utils.to_integer(words) words = 1 if words <= 0 wordlist = begin input.split(" ", words + 1) rescue RangeError # integer too big for String#split, but we can semantically assume no truncation is needed return input if words + 1 > MAX_I32 raise # unexpected error end return input if wordlist.length <= words wordlist.pop truncate_string = Utils.to_s(truncate_string) wordlist.join(" ").concat(truncate_string) end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Splits a string into an array of substrings based on a given separator. # @liquid_syntax string | split: string # @liquid_return [array[string]] def split(input, pattern) pattern = Utils.to_s(pattern) input = Utils.to_s(input) input.split(pattern) end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Removes leading and trailing whitespace and collapses consecutive whitespace to a single space. # @liquid_syntax string | squish # @liquid_return [string] def squish(input) return if input.nil? Utils.to_s(input).strip.gsub(/\s+/, ' ') end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Strips all whitespace from the left and right of a string. # @liquid_syntax string | strip # @liquid_return [string] def strip(input) input = Utils.to_s(input) input.strip end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Strips all whitespace from the left of a string. # @liquid_syntax string | lstrip # @liquid_return [string] def lstrip(input) input = Utils.to_s(input) input.lstrip end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Strips all whitespace from the right of a string. # @liquid_syntax string | rstrip # @liquid_return [string] def rstrip(input) input = Utils.to_s(input) input.rstrip end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Strips all HTML tags from a string. # @liquid_syntax string | strip_html # @liquid_return [string] def strip_html(input) input = Utils.to_s(input) empty = '' result = input.gsub(STRIP_HTML_BLOCKS, empty) result.gsub!(STRIP_HTML_TAGS, empty) result end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Strips all newline characters (line breaks) from a string. # @liquid_syntax string | strip_newlines # @liquid_return [string] def strip_newlines(input) input = Utils.to_s(input) input.gsub(/\r?\n/, '') end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Combines all of the items in an array into a single string, separated by a space. # @liquid_syntax array | join # @liquid_return [string] def join(input, glue = ' ') glue = Utils.to_s(glue) InputIterator.new(input, context).join(glue) end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Sorts the items in an array in case-sensitive alphabetical, or numerical, order. # @liquid_syntax array | sort # @liquid_return [array[untyped]] def sort(input, property = nil) ary = InputIterator.new(input, context) return [] if ary.empty? if property.nil? ary.sort do |a, b| nil_safe_compare(a, b) end elsif ary.all? { |el| el.respond_to?(:[]) } begin ary.sort { |a, b| nil_safe_compare(a[property], b[property]) } rescue TypeError raise_property_error(property) end end end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Sorts the items in an array in case-insensitive alphabetical order. # @liquid_description # > Caution: # > You shouldn't use the `sort_natural` filter to sort numerical values. When comparing items an array, each item is converted to a # > string, so sorting on numerical values can lead to unexpected results. # @liquid_syntax array | sort_natural # @liquid_return [array[untyped]] def sort_natural(input, property = nil) ary = InputIterator.new(input, context) return [] if ary.empty? if property.nil? ary.sort do |a, b| nil_safe_casecmp(a, b) end elsif ary.all? { |el| el.respond_to?(:[]) } begin ary.sort { |a, b| nil_safe_casecmp(a[property], b[property]) } rescue TypeError raise_property_error(property) end end end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Filters an array to include only items with a specific property value. # @liquid_description # This requires you to provide both the property name and the associated value. # @liquid_syntax array | where: string, string # @liquid_return [array[untyped]] def where(input, property, target_value = nil) filter_array(input, property, target_value) { |ary, &block| ary.select(&block) } end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Filters an array to exclude items with a specific property value. # @liquid_description # This requires you to provide both the property name and the associated value. # @liquid_syntax array | reject: string, string # @liquid_return [array[untyped]] def reject(input, property, target_value = nil) filter_array(input, property, target_value) { |ary, &block| ary.reject(&block) } end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Tests if any item in an array has a specific property value. # @liquid_description # This requires you to provide both the property name and the associated value. # @liquid_syntax array | has: string, string # @liquid_return [boolean] def has(input, property, target_value = nil) filter_array(input, property, target_value, false) { |ary, &block| ary.any?(&block) } end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Returns the first item in an array with a specific property value. # @liquid_description # This requires you to provide both the property name and the associated value. # @liquid_syntax array | find: string, string # @liquid_return [untyped] def find(input, property, target_value = nil) filter_array(input, property, target_value, nil) { |ary, &block| ary.find(&block) } end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Returns the index of the first item in an array with a specific property value. # @liquid_description # This requires you to provide both the property name and the associated value. # @liquid_syntax array | find_index: string, string # @liquid_return [number] def find_index(input, property, target_value = nil) filter_array(input, property, target_value, nil) { |ary, &block| ary.find_index(&block) } end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Removes any duplicate items in an array. # @liquid_syntax array | uniq # @liquid_return [array[untyped]] def uniq(input, property = nil) ary = InputIterator.new(input, context) if property.nil? ary.uniq elsif ary.empty? # The next two cases assume a non-empty array. [] else ary.uniq do |item| item[property] rescue TypeError raise_property_error(property) rescue NoMethodError return nil unless item.respond_to?(:[]) raise end end end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Reverses the order of the items in an array. # @liquid_syntax array | reverse # @liquid_return [array[untyped]] def reverse(input) ary = InputIterator.new(input, context) ary.reverse end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Creates an array of values from a specific property of the items in an array. # @liquid_syntax array | map: string # @liquid_return [array[untyped]] def map(input, property) InputIterator.new(input, context).map do |e| e = e.call if e.is_a?(Proc) if property == "to_liquid" e elsif e.respond_to?(:[]) r = e[property] r.is_a?(Proc) ? r.call : r end end rescue TypeError raise_property_error(property) end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Removes any `nil` items from an array. # @liquid_syntax array | compact # @liquid_return [array[untyped]] def compact(input, property = nil) ary = InputIterator.new(input, context) if property.nil? ary.compact elsif ary.empty? # The next two cases assume a non-empty array. [] else ary.reject do |item| item[property].nil? rescue TypeError raise_property_error(property) rescue NoMethodError return nil unless item.respond_to?(:[]) raise end end end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Replaces any instance of a substring inside a string with a given string. # @liquid_syntax string | replace: string, string # @liquid_return [string] def replace(input, string, replacement = '') string = Utils.to_s(string) replacement = Utils.to_s(replacement) input = Utils.to_s(input) input.gsub(string, replacement) end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Replaces the first instance of a substring inside a string with a given string. # @liquid_syntax string | replace_first: string, string # @liquid_return [string] def replace_first(input, string, replacement = '') string = Utils.to_s(string) replacement = Utils.to_s(replacement) input = Utils.to_s(input) input.sub(string, replacement) end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Replaces the last instance of a substring inside a string with a given string. # @liquid_syntax string | replace_last: string, string # @liquid_return [string] def replace_last(input, string, replacement) input = Utils.to_s(input) string = Utils.to_s(string) replacement = Utils.to_s(replacement) start_index = input.rindex(string) return input unless start_index output = input.dup output[start_index, string.length] = replacement output end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Removes any instance of a substring inside a string. # @liquid_syntax string | remove: string # @liquid_return [string] def remove(input, string) replace(input, string, '') end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Removes the first instance of a substring inside a string. # @liquid_syntax string | remove_first: string # @liquid_return [string] def remove_first(input, string) replace_first(input, string, '') end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Removes the last instance of a substring inside a string. # @liquid_syntax string | remove_last: string # @liquid_return [string] def remove_last(input, string) replace_last(input, string, '') end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Adds a given string to the end of a string. # @liquid_syntax string | append: string # @liquid_return [string] def append(input, string) input = Utils.to_s(input) string = Utils.to_s(string) input + string end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Concatenates (combines) two arrays. # @liquid_description # > Note: # > The `concat` filter won't filter out duplicates. If you want to remove duplicates, then you need to use the # > [`uniq` filter](/docs/api/liquid/filters/uniq). # @liquid_syntax array | concat: array # @liquid_return [array[untyped]] def concat(input, array) unless array.respond_to?(:to_ary) raise ArgumentError, "concat filter requires an array argument" end InputIterator.new(input, context).concat(array) end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Adds a given string to the beginning of a string. # @liquid_syntax string | prepend: string # @liquid_return [string] def prepend(input, string) input = Utils.to_s(input) string = Utils.to_s(string) string + input end # @liquid_public_docs # @liquid_type filter # @liquid_category string # @liquid_summary # Converts newlines (`\n`) in a string to HTML line breaks (`
`). # @liquid_syntax string | newline_to_br # @liquid_return [string] def newline_to_br(input) input = Utils.to_s(input) input.gsub(/\r?\n/, "
\n") end # @liquid_public_docs # @liquid_type filter # @liquid_category date # @liquid_summary # Formats a date according to a specified format string. # @liquid_description # This filter formats a date using various format specifiers. If the format string is empty, # the original input is returned. If the input cannot be converted to a date, the original input is returned. # # The following format specifiers can be used: # # %a - The abbreviated weekday name (``Sun'') # %A - The full weekday name (``Sunday'') # %b - The abbreviated month name (``Jan'') # %B - The full month name (``January'') # %c - The preferred local date and time representation # %d - Day of the month (01..31) # %H - Hour of the day, 24-hour clock (00..23) # %I - Hour of the day, 12-hour clock (01..12) # %j - Day of the year (001..366) # %m - Month of the year (01..12) # %M - Minute of the hour (00..59) # %p - Meridian indicator (``AM'' or ``PM'') # %s - Number of seconds since 1970-01-01 00:00:00 UTC. # %S - Second of the minute (00..60) # %U - Week number of the current year, # starting with the first Sunday as the first # day of the first week (00..53) # %W - Week number of the current year, # starting with the first Monday as the first # day of the first week (00..53) # %w - Day of the week (Sunday is 0, 0..6) # %x - Preferred representation for the date alone, no time # %X - Preferred representation for the time alone, no date # %y - Year without a century (00..99) # %Y - Year with century # %Z - Time zone name # %% - Literal ``%'' character # @liquid_syntax date | date: string # @liquid_return [string] def date(input, format) str_format = Utils.to_s(format) return input if str_format.empty? return input unless (date = Utils.to_date(input)) date.strftime(str_format) end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Returns the first item in an array. # @liquid_syntax array | first # @liquid_return [untyped] def first(array) # ActiveSupport returns "" for empty strings, not nil return array[0] || "" if array.is_a?(String) array.first if array.respond_to?(:first) end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Returns the last item in an array. # @liquid_syntax array | last # @liquid_return [untyped] def last(array) # ActiveSupport returns "" for empty strings, not nil return array[-1] || "" if array.is_a?(String) array.last if array.respond_to?(:last) end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Returns the absolute value of a number. # @liquid_syntax number | abs # @liquid_return [number] def abs(input) result = Utils.to_number(input).abs result.is_a?(BigDecimal) ? result.to_f : result end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Adds two numbers. # @liquid_syntax number | plus: number # @liquid_return [number] def plus(input, operand) apply_operation(input, operand, :+) end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Subtracts a given number from another number. # @liquid_syntax number | minus: number # @liquid_return [number] def minus(input, operand) apply_operation(input, operand, :-) end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Multiplies a number by a given number. # @liquid_syntax number | times: number # @liquid_return [number] def times(input, operand) apply_operation(input, operand, :*) end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Divides a number by a given number. The `divided_by` filter produces a result of the same type as the divisor. This means if you divide by an integer, the result will be an integer, and if you divide by a float, the result will be a float. # @liquid_syntax number | divided_by: number # @liquid_return [number] def divided_by(input, operand) apply_operation(input, operand, :/) rescue ::ZeroDivisionError => e raise Liquid::ZeroDivisionError, e.message end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Returns the remainder of dividing a number by a given number. # @liquid_syntax number | modulo: number # @liquid_return [number] def modulo(input, operand) apply_operation(input, operand, :%) rescue ::ZeroDivisionError => e raise Liquid::ZeroDivisionError, e.message end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Rounds a number to the nearest integer. # @liquid_syntax number | round # @liquid_return [number] def round(input, n = 0) result = Utils.to_number(input).round(Utils.to_number(n)) result = result.to_f if result.is_a?(BigDecimal) result = result.to_i if n == 0 result rescue ::FloatDomainError => e raise Liquid::FloatDomainError, e.message end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Rounds a number up to the nearest integer. # @liquid_syntax number | ceil # @liquid_return [number] def ceil(input) Utils.to_number(input).ceil.to_i rescue ::FloatDomainError => e raise Liquid::FloatDomainError, e.message end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Rounds a number down to the nearest integer. # @liquid_syntax number | floor # @liquid_return [number] def floor(input) Utils.to_number(input).floor.to_i rescue ::FloatDomainError => e raise Liquid::FloatDomainError, e.message end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Limits a number to a minimum value. # @liquid_syntax number | at_least # @liquid_return [number] def at_least(input, n) min_value = Utils.to_number(n) result = Utils.to_number(input) result = min_value if min_value > result result.is_a?(BigDecimal) ? result.to_f : result end # @liquid_public_docs # @liquid_type filter # @liquid_category math # @liquid_summary # Limits a number to a maximum value. # @liquid_syntax number | at_most # @liquid_return [number] def at_most(input, n) max_value = Utils.to_number(n) result = Utils.to_number(input) result = max_value if max_value < result result.is_a?(BigDecimal) ? result.to_f : result end # @liquid_public_docs # @liquid_type filter # @liquid_category default # @liquid_summary # Sets a default value for any variable whose value is one of the following: # # - [`empty`](/docs/api/liquid/basics#empty) # - [`false`](/docs/api/liquid/basics#truthy-and-falsy) # - [`nil`](/docs/api/liquid/basics#nil) # @liquid_syntax variable | default: variable # @liquid_return [untyped] # @liquid_optional_param allow_false: [boolean] Whether to use false values instead of the default. def default(input, default_value = '', options = {}) options = {} unless options.is_a?(Hash) false_check = options['allow_false'] ? input.nil? : !Liquid::Utils.to_liquid_value(input) false_check || (input.respond_to?(:empty?) && input.empty?) ? default_value : input end # @liquid_public_docs # @liquid_type filter # @liquid_category array # @liquid_summary # Returns the sum of all elements in an array. # @liquid_syntax array | sum # @liquid_return [number] def sum(input, property = nil) ary = InputIterator.new(input, context) return 0 if ary.empty? values_for_sum = ary.map do |item| if property.nil? item elsif item.respond_to?(:[]) item[property] else 0 end rescue TypeError raise_property_error(property) end result = InputIterator.new(values_for_sum, context).sum do |item| Utils.to_number(item) end result.is_a?(BigDecimal) ? result.to_f : result end private attr_reader :context def filter_array(input, property, target_value, default_value = [], &block) ary = InputIterator.new(input, context) return default_value if ary.empty? block.call(ary) do |item| if target_value.nil? item[property] else item[property] == target_value end rescue TypeError raise_property_error(property) rescue NoMethodError return nil unless item.respond_to?(:[]) raise end end def raise_property_error(property) raise Liquid::ArgumentError, "cannot select the property '#{Utils.to_s(property)}'" end def apply_operation(input, operand, operation) result = Utils.to_number(input).send(operation, Utils.to_number(operand)) result.is_a?(BigDecimal) ? result.to_f : result end def nil_safe_compare(a, b) result = a <=> b if result result elsif a.nil? 1 elsif b.nil? -1 else raise Liquid::ArgumentError, "cannot sort values of incompatible types" end end def nil_safe_casecmp(a, b) if !a.nil? && !b.nil? a.to_s.casecmp(b.to_s) elsif a.nil? && b.nil? 0 else a.nil? ? 1 : -1 end end class InputIterator include Enumerable def initialize(input, context) @context = context @input = if input.is_a?(Array) input.flatten elsif input.is_a?(Hash) [input] elsif input.is_a?(Enumerable) input else Array(input) end end def join(glue) first = true output = +"" each do |item| if first first = false else output << glue end output << Liquid::Utils.to_s(item) end output end def concat(args) to_a.concat(args) end def reverse reverse_each.to_a end def uniq(&block) to_a.uniq do |item| item = Utils.to_liquid_value(item) block ? yield(item) : item end end def compact to_a.compact end def empty? @input.each { return false } true end def each @input.each do |e| e = e.respond_to?(:to_liquid) ? e.to_liquid : e e.context = @context if e.respond_to?(:context=) yield(e) end end end end end ================================================ FILE: lib/liquid/strainer_template.rb ================================================ # frozen_string_literal: true require 'set' module Liquid # StrainerTemplate is the computed class for the filters system. # New filters are mixed into the strainer class which is then instantiated for each liquid template render run. # # The Strainer only allows method calls defined in filters given to it via StrainerFactory.add_global_filter, # Context#add_filters or Template.register_filter class StrainerTemplate def initialize(context) @context = context end class << self def add_filter(filter) return if include?(filter) invokable_non_public_methods = (filter.private_instance_methods + filter.protected_instance_methods).select { |m| invokable?(m) } if invokable_non_public_methods.any? raise MethodOverrideError, "Filter overrides registered public methods as non public: #{invokable_non_public_methods.join(', ')}" end include(filter) filter_methods.merge(filter.public_instance_methods.map(&:to_s)) end def invokable?(method) filter_methods.include?(method.to_s) end def inherited(subclass) super subclass.instance_variable_set(:@filter_methods, @filter_methods.dup) end def filter_method_names filter_methods.map(&:to_s).to_a end private def filter_methods @filter_methods ||= Set.new end end def invoke(method, *args) if self.class.invokable?(method) send(method, *args) elsif @context.strict_filters raise Liquid::UndefinedFilter, "undefined filter #{method}" else args.first end rescue ::ArgumentError => e raise Liquid::ArgumentError, e.message, e.backtrace end end end ================================================ FILE: lib/liquid/tablerowloop_drop.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type object # @liquid_name tablerowloop # @liquid_summary # Information about a parent [`tablerow` loop](/docs/api/liquid/tags/tablerow). class TablerowloopDrop < Drop def initialize(length, cols) @length = length @row = 1 @col = 1 @cols = cols @index = 0 end # @liquid_public_docs # @liquid_summary # The total number of iterations in the loop. # @liquid_return [number] attr_reader :length # @liquid_public_docs # @liquid_summary # The 1-based index of the current column. # @liquid_return [number] attr_reader :col # @liquid_public_docs # @liquid_summary # The 1-based index of current row. # @liquid_return [number] attr_reader :row # @liquid_public_docs # @liquid_summary # The 1-based index of the current iteration. # @liquid_return [number] def index @index + 1 end # @liquid_public_docs # @liquid_summary # The 0-based index of the current iteration. # @liquid_return [number] def index0 @index end # @liquid_public_docs # @liquid_summary # The 0-based index of the current column. # @liquid_return [number] def col0 @col - 1 end # @liquid_public_docs # @liquid_summary # The 1-based index of the current iteration, in reverse order. # @liquid_return [number] def rindex @length - @index end # @liquid_public_docs # @liquid_summary # The 0-based index of the current iteration, in reverse order. # @liquid_return [number] def rindex0 @length - @index - 1 end # @liquid_public_docs # @liquid_summary # Returns `true` if the current iteration is the first. Returns `false` if not. # @liquid_return [boolean] def first @index == 0 end # @liquid_public_docs # @liquid_summary # Returns `true` if the current iteration is the last. Returns `false` if not. # @liquid_return [boolean] def last @index == @length - 1 end # @liquid_public_docs # @liquid_summary # Returns `true` if the current column is the first in the row. Returns `false` if not. # @liquid_return [boolean] def col_first @col == 1 end # @liquid_public_docs # @liquid_summary # Returns `true` if the current column is the last in the row. Returns `false` if not. # @liquid_return [boolean] def col_last @col == @cols end protected def increment! @index += 1 if @col == @cols @col = 1 @row += 1 else @col += 1 end end end end ================================================ FILE: lib/liquid/tag/disableable.rb ================================================ # frozen_string_literal: true module Liquid class Tag module Disableable def render_to_output_buffer(context, output) if context.tag_disabled?(tag_name) output << disabled_error(context) return end super end def disabled_error(context) # raise then rescue the exception so that the Context#exception_renderer can re-raise it raise DisabledError, "#{tag_name} #{parse_context[:locale].t('errors.disabled.tag')}" rescue DisabledError => exc context.handle_error(exc, line_number) end end end end ================================================ FILE: lib/liquid/tag/disabler.rb ================================================ # frozen_string_literal: true module Liquid class Tag module Disabler def render_to_output_buffer(context, output) context.with_disabled_tags(self.class.disabled_tags) do super end end end end end ================================================ FILE: lib/liquid/tag.rb ================================================ # frozen_string_literal: true require 'liquid/tag/disabler' require 'liquid/tag/disableable' module Liquid class Tag attr_reader :nodelist, :tag_name, :line_number, :parse_context alias_method :options, :parse_context include ParserSwitching class << self def parse(tag_name, markup, tokenizer, parse_context) tag = new(tag_name, markup, parse_context) tag.parse(tokenizer) tag end def disable_tags(*tag_names) tag_names += disabled_tags define_singleton_method(:disabled_tags) { tag_names } prepend(Disabler) end private :new protected def disabled_tags [] end end def initialize(tag_name, markup, parse_context) @tag_name = tag_name @markup = markup @parse_context = parse_context @line_number = parse_context.line_number end def parse(_tokens) end def raw "#{@tag_name} #{@markup}" end def name self.class.name.downcase end def render(_context) '' end # For backwards compatibility with custom tags. In a future release, the semantics # of the `render_to_output_buffer` method will become the default and the `render` # method will be removed. def render_to_output_buffer(context, output) render_result = render(context) output << render_result if render_result output end def blank? false end private def safe_parse_expression(parser) parse_context.safe_parse_expression(parser) end def parse_expression(markup, safe: false) parse_context.parse_expression(markup, safe: safe) end end end ================================================ FILE: lib/liquid/tags/assign.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category variable # @liquid_name assign # @liquid_summary # Creates a new variable. # @liquid_description # You can create variables of any [basic type](/docs/api/liquid/basics#types), [object](/docs/api/liquid/objects), or object property. # # > Caution: # > Predefined Liquid objects can be overridden by variables with the same name. # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name. # @liquid_syntax # {% assign variable_name = value %} # @liquid_syntax_keyword variable_name The name of the variable being created. # @liquid_syntax_keyword value The value you want to assign to the variable. class Assign < Tag Syntax = /(#{VariableSignature}+)\s*=\s*(.*)\s*/om # @api private def self.raise_syntax_error(parse_context) raise Liquid::SyntaxError, parse_context.locale.t('errors.syntax.assign') end attr_reader :to, :from def initialize(tag_name, markup, parse_context) super if markup =~ Syntax @to = Regexp.last_match(1) @from = Variable.new(Regexp.last_match(2), parse_context) else self.class.raise_syntax_error(parse_context) end end def render_to_output_buffer(context, output) val = @from.render(context) context.scopes.last[@to] = val context.resource_limits.increment_assign_score(assign_score_of(val)) output end def blank? true end private def assign_score_of(val) if val.instance_of?(String) val.bytesize elsif val.instance_of?(Array) sum = 1 # Uses #each to avoid extra allocations. val.each { |child| sum += assign_score_of(child) } sum elsif val.instance_of?(Hash) sum = 1 val.each do |key, entry_value| sum += assign_score_of(key) sum += assign_score_of(entry_value) end sum else 1 end end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [@node.from] end end end end ================================================ FILE: lib/liquid/tags/break.rb ================================================ # frozen_string_literal: true module Liquid # Break tag to be used to break out of a for loop. # # == Basic Usage: # {% for item in collection %} # {% if item.condition %} # {% break %} # {% endif %} # {% endfor %} # # @liquid_public_docs # @liquid_type tag # @liquid_category iteration # @liquid_name break # @liquid_summary # Stops a [`for` loop](/docs/api/liquid/tags/for) from iterating. # @liquid_syntax # {% break %} class Break < Tag INTERRUPT = BreakInterrupt.new.freeze def render_to_output_buffer(context, output) context.push_interrupt(INTERRUPT) output end end end ================================================ FILE: lib/liquid/tags/capture.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category variable # @liquid_name capture # @liquid_summary # Creates a new variable with a string value. # @liquid_description # You can create complex strings with Liquid logic and variables. # # > Caution: # > Predefined Liquid objects can be overridden by variables with the same name. # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name. # @liquid_syntax # {% capture variable %} # value # {% endcapture %} # @liquid_syntax_keyword variable The name of the variable being created. # @liquid_syntax_keyword value The value you want to assign to the variable. class Capture < Block Syntax = /(#{VariableSignature}+)/o def initialize(tag_name, markup, options) super if markup =~ Syntax @to = Regexp.last_match(1) else raise SyntaxError, options[:locale].t("errors.syntax.capture") end end def render_to_output_buffer(context, output) context.resource_limits.with_capture do capture_output = render(context) context.scopes.last[@to] = capture_output end output end def blank? true end end end ================================================ FILE: lib/liquid/tags/case.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category conditional # @liquid_name case # @liquid_summary # Renders a specific expression depending on the value of a specific variable. # @liquid_syntax # {% case variable %} # {% when first_value %} # first_expression # {% when second_value %} # second_expression # {% else %} # third_expression # {% endcase %} # @liquid_syntax_keyword variable The name of the variable you want to base your case statement on. # @liquid_syntax_keyword first_value A specific value to check for. # @liquid_syntax_keyword second_value A specific value to check for. # @liquid_syntax_keyword first_expression An expression to be rendered when the variable's value matches `first_value`. # @liquid_syntax_keyword second_expression An expression to be rendered when the variable's value matches `second_value`. # @liquid_syntax_keyword third_expression An expression to be rendered when the variable's value has no match. class Case < Block Syntax = /(#{QuotedFragment})/o WhenSyntax = /(#{QuotedFragment})(?:(?:\s+or\s+|\s*\,\s*)(#{QuotedFragment}.*))?/om attr_reader :blocks, :left def initialize(tag_name, markup, options) super @blocks = [] parse_with_selected_parser(markup) end def parse(tokens) body = case_body = new_body body = @blocks.last.attachment while parse_body(body, tokens) @blocks.reverse_each do |condition| body = condition.attachment unless body.frozen? body.remove_blank_strings if blank? body.freeze end end case_body.freeze end def nodelist @blocks.map(&:attachment) end def unknown_tag(tag, markup, tokens) case tag when 'when' record_when_condition(markup) when 'else' record_else_condition(markup) else super end end def render_to_output_buffer(context, output) execute_else_block = true @blocks.each do |block| if block.else? block.attachment.render_to_output_buffer(context, output) if execute_else_block next end result = Liquid::Utils.to_liquid_value( block.evaluate(context), ) if result execute_else_block = false block.attachment.render_to_output_buffer(context, output) end end output end private def strict2_parse(markup) parser = @parse_context.new_parser(markup) @left = safe_parse_expression(parser) parser.consume(:end_of_string) end def strict_parse(markup) lax_parse(markup) end def lax_parse(markup) if markup =~ Syntax @left = parse_expression(Regexp.last_match(1)) else raise SyntaxError, options[:locale].t("errors.syntax.case") end end def record_when_condition(markup) body = new_body if strict2_mode? parse_strict2_when(markup, body) else parse_lax_when(markup, body) end end def parse_strict2_when(markup, body) parser = @parse_context.new_parser(markup) loop do expr = Condition.parse_expression(parse_context, parser.expression, safe: true) block = Condition.new(@left, '==', expr) block.attach(body) @blocks << block break unless parser.id?('or') || parser.consume?(:comma) end parser.consume(:end_of_string) end def parse_lax_when(markup, body) while markup unless markup =~ WhenSyntax raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_when") end markup = Regexp.last_match(2) block = Condition.new(@left, '==', Condition.parse_expression(parse_context, Regexp.last_match(1))) block.attach(body) @blocks << block end end def record_else_condition(markup) unless markup.strip.empty? raise SyntaxError, options[:locale].t("errors.syntax.case_invalid_else") end block = ElseCondition.new block.attach(new_body) @blocks << block end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [@node.left] + @node.blocks end end end end ================================================ FILE: lib/liquid/tags/comment.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category syntax # @liquid_name comment # @liquid_summary # Prevents an expression from being rendered or output. # @liquid_description # Any text inside `comment` tags won't be output, and any Liquid code will be parsed, but not executed. # @liquid_syntax # {% comment %} # content # {% endcomment %} # @liquid_syntax_keyword content The content of the comment. class Comment < Block def render_to_output_buffer(_context, output) output end def unknown_tag(_tag, _markup, _tokens) end def blank? true end private def parse_body(body, tokenizer) if parse_context.depth >= MAX_DEPTH raise StackLevelError, "Nesting too deep" end parse_context.depth += 1 comment_tag_depth = 1 begin # Consume tokens without creating child nodes. # The children tag doesn't require to be a valid Liquid except the comment and raw tag. # The child comment and raw tag must be closed. while (token = tokenizer.send(:shift)) tag_name = if tokenizer.for_liquid_tag next if token.empty? || token.match?(BlockBody::WhitespaceOrNothing) tag_name_match = BlockBody::LiquidTagToken.match(token) next if tag_name_match.nil? tag_name_match[1] else token =~ BlockBody::FullToken Regexp.last_match(2) end case tag_name when "raw" parse_raw_tag_body(tokenizer) when "comment" comment_tag_depth += 1 when "endcomment" comment_tag_depth -= 1 end if comment_tag_depth.zero? parse_context.trim_whitespace = (token[-3] == WhitespaceControl) unless tokenizer.for_liquid_tag return false end end raise_tag_never_closed(block_name) ensure parse_context.depth -= 1 end false end def parse_raw_tag_body(tokenizer) while (token = tokenizer.send(:shift)) return if token =~ BlockBody::FullTokenPossiblyInvalid && "endraw" == Regexp.last_match(2) end raise_tag_never_closed("raw") end end end ================================================ FILE: lib/liquid/tags/continue.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category iteration # @liquid_name continue # @liquid_summary # Causes a [`for` loop](/docs/api/liquid/tags/for) to skip to the next iteration. # @liquid_syntax # {% continue %} class Continue < Tag INTERRUPT = ContinueInterrupt.new.freeze def render_to_output_buffer(context, output) context.push_interrupt(INTERRUPT) output end end end ================================================ FILE: lib/liquid/tags/cycle.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category iteration # @liquid_name cycle # @liquid_summary # Loops through a group of strings and outputs them one at a time for each iteration of a [`for` loop](/docs/api/liquid/tags/for). # @liquid_description # The `cycle` tag must be used inside a `for` loop. # # > Tip: # > Use the `cycle` tag to output text in a predictable pattern. For example, to apply odd/even classes to rows in a table. # @liquid_syntax # {% cycle string, string, ... %} class Cycle < Tag SimpleSyntax = /\A#{QuotedFragment}+/o NamedSyntax = /\A(#{QuotedFragment})\s*\:\s*(.*)/om UNNAMED_CYCLE_PATTERN = /\w+:0x\h{8}/ attr_reader :variables def initialize(tag_name, markup, options) super parse_with_selected_parser(markup) end def named? @is_named end def render_to_output_buffer(context, output) context.registers[:cycle] ||= {} key = context.evaluate(@name) iteration = context.registers[:cycle][key].to_i val = context.evaluate(@variables[iteration]) if val.is_a?(Array) val = val.join elsif !val.is_a?(String) val = val.to_s end output << val iteration += 1 iteration = 0 if iteration >= @variables.size context.registers[:cycle][key] = iteration output end private # cycle [name:] expression(, expression)* def strict2_parse(markup) p = @parse_context.new_parser(markup) @variables = [] raise SyntaxError, options[:locale].t("errors.syntax.cycle") if p.look(:end_of_string) first_expression = safe_parse_expression(p) if p.look(:colon) # cycle name: expr1, expr2, ... @name = first_expression @is_named = true p.consume(:colon) # After the colon, parse the first variable (required for named cycles) @variables << maybe_dup_lookup(safe_parse_expression(p)) else # cycle expr1, expr2, ... @variables << maybe_dup_lookup(first_expression) end # Parse remaining comma-separated expressions while p.consume?(:comma) break if p.look(:end_of_string) @variables << maybe_dup_lookup(safe_parse_expression(p)) end p.consume(:end_of_string) unless @is_named @name = @variables.to_s @is_named = !@name.match?(UNNAMED_CYCLE_PATTERN) end end def strict_parse(markup) lax_parse(markup) end def lax_parse(markup) case markup when NamedSyntax @variables = variables_from_string(Regexp.last_match(2)) @name = parse_expression(Regexp.last_match(1)) @is_named = true when SimpleSyntax @variables = variables_from_string(markup) @name = @variables.to_s @is_named = !@name.match?(UNNAMED_CYCLE_PATTERN) else raise SyntaxError, options[:locale].t("errors.syntax.cycle") end end def variables_from_string(markup) markup.split(',').collect do |var| var =~ /\s*(#{QuotedFragment})\s*/o next unless Regexp.last_match(1) var = parse_expression(Regexp.last_match(1)) maybe_dup_lookup(var) end.compact end # For backwards compatibility, whenever a lookup is used in an unnamed cycle, # we make it so that the @variables.to_s produces different strings for cycles # called with the same arguments (since @variables.to_s is used as the cycle counter key) # This makes it so {% cycle a, b %} and {% cycle a, b %} have independent counters even if a and b share value. # This is not true for literal values, {% cycle "a", "b" %} and {% cycle "a", "b" %} share the same counter. # I was really scratching my head about this one, but migrating away from this would be more headache # than it's worth. So we're keeping this quirk for now. def maybe_dup_lookup(var) var.is_a?(VariableLookup) ? var.dup : var end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children Array(@node.variables) end end end end ================================================ FILE: lib/liquid/tags/decrement.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category variable # @liquid_name decrement # @liquid_summary # Creates a new variable, with a default value of -1, that's decreased by 1 with each subsequent call. # # > Caution: # > Predefined Liquid objects can be overridden by variables with the same name. # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name. # @liquid_description # Variables that are declared with `decrement` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates), # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across # [snippets](/themes/architecture/snippets) included in the file. # # Similarly, variables that are created with `decrement` are independent from those created with [`assign`](/docs/api/liquid/tags/assign) # and [`capture`](/docs/api/liquid/tags/capture). However, `decrement` and [`increment`](/docs/api/liquid/tags/increment) share # variables. # @liquid_syntax # {% decrement variable_name %} # @liquid_syntax_keyword variable_name The name of the variable being decremented. class Decrement < Tag attr_reader :variable_name def initialize(tag_name, markup, options) super @variable_name = markup.strip end def render_to_output_buffer(context, output) counter_environment = context.environments.first value = counter_environment[@variable_name] || 0 value -= 1 counter_environment[@variable_name] = value output << value.to_s output end end end ================================================ FILE: lib/liquid/tags/doc.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category syntax # @liquid_name doc # @liquid_summary # Documents template elements with annotations. # @liquid_description # The `doc` tag allows developers to include documentation within Liquid # templates. Any content inside `doc` tags is not rendered or outputted. # Liquid code inside will be parsed but not executed. This facilitates # tooling support for features like code completion, linting, and inline # documentation. # # For detailed documentation syntax and examples, see the # [`LiquidDoc` reference](/docs/storefronts/themes/tools/liquid-doc). # # @liquid_syntax # {% doc %} # Renders a message. # # @param {string} foo - A string value. # @param {string} [bar] - An optional string value. # # @example # {% render 'message', foo: 'Hello', bar: 'World' %} # {% enddoc %} class Doc < Block NO_UNEXPECTED_ARGS = /\A\s*\z/ def initialize(tag_name, markup, parse_context) super ensure_valid_markup(tag_name, markup, parse_context) end def parse(tokens) @body = +"" while (token = tokens.shift) tag_name = token =~ BlockBody::FullTokenPossiblyInvalid && Regexp.last_match(2) raise_nested_doc_error if tag_name == @tag_name if tag_name == block_delimiter parse_context.trim_whitespace = (token[-3] == WhitespaceControl) @body << Regexp.last_match(1) if Regexp.last_match(1) != "" return end @body << token unless token.empty? end raise_tag_never_closed(block_name) end def render_to_output_buffer(_context, output) output end def blank? @body.empty? end def nodelist [@body] end private def ensure_valid_markup(tag_name, markup, parse_context) unless NO_UNEXPECTED_ARGS.match?(markup) raise SyntaxError, parse_context.locale.t("errors.syntax.block_tag_unexpected_args", tag: tag_name) end end def raise_nested_doc_error raise SyntaxError, parse_context.locale.t("errors.syntax.doc_invalid_nested") end end end ================================================ FILE: lib/liquid/tags/echo.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category syntax # @liquid_name echo # @liquid_summary # Outputs an expression. # @liquid_description # Using the `echo` tag is the same as wrapping an expression in curly brackets (`{{` and `}}`). However, unlike the curly # bracket method, you can use the `echo` tag inside [`liquid` tags](/docs/api/liquid/tags/liquid). # # > Tip: # > You can use [filters](/docs/api/liquid/filters) on expressions inside `echo` tags. # @liquid_syntax # {% liquid # echo expression # %} # @liquid_syntax_keyword expression The expression to be output. class Echo < Tag attr_reader :variable def initialize(tag_name, markup, parse_context) super @variable = Variable.new(markup, parse_context) end def render(context) @variable.render_to_output_buffer(context, +'') end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [@node.variable] end end end end ================================================ FILE: lib/liquid/tags/for.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category iteration # @liquid_name for # @liquid_summary # Renders an expression for every item in an array. # @liquid_description # You can do a maximum of 50 iterations with a `for` loop. If you need to iterate over more than 50 items, then use the # [`paginate` tag](/docs/api/liquid/tags/paginate) to split the items over multiple pages. # # > Tip: # > Every `for` loop has an associated [`forloop` object](/docs/api/liquid/objects/forloop) with information about the loop. # @liquid_syntax # {% for variable in array %} # expression # {% endfor %} # @liquid_syntax_keyword variable The current item in the array. # @liquid_syntax_keyword array The array to iterate over. # @liquid_syntax_keyword expression The expression to render for each iteration. # @liquid_optional_param limit: [number] The number of iterations to perform. # @liquid_optional_param offset: [number] The 1-based index to start iterating at. # @liquid_optional_param range [untyped] A custom numeric range to iterate over. # @liquid_optional_param reversed [untyped] Iterate in reverse order. class For < Block Syntax = /\A(#{VariableSegment}+)\s+in\s+(#{QuotedFragment}+)\s*(reversed)?/o attr_reader :collection_name, :variable_name, :limit, :from def initialize(tag_name, markup, options) super @from = @limit = nil parse_with_selected_parser(markup) @for_block = new_body @else_block = nil end def parse(tokens) if parse_body(@for_block, tokens) parse_body(@else_block, tokens) end if blank? @else_block&.remove_blank_strings @for_block.remove_blank_strings end @else_block&.freeze @for_block.freeze end def nodelist @else_block ? [@for_block, @else_block] : [@for_block] end def unknown_tag(tag, markup, tokens) return super unless tag == 'else' @else_block = new_body end def render_to_output_buffer(context, output) segment = collection_segment(context) if segment.empty? render_else(context, output) else render_segment(context, output, segment) end output end protected def lax_parse(markup) if markup =~ Syntax @variable_name = Regexp.last_match(1) collection_name = Regexp.last_match(2) @reversed = !!Regexp.last_match(3) @name = "#{@variable_name}-#{collection_name}" @collection_name = parse_expression(collection_name) markup.scan(TagAttributes) do |key, value| set_attribute(key, value) end else raise SyntaxError, options[:locale].t("errors.syntax.for") end end def strict_parse(markup) p = @parse_context.new_parser(markup) @variable_name = p.consume(:id) raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") unless p.id?('in') collection_name = p.expression @collection_name = parse_expression(collection_name, safe: true) @name = "#{@variable_name}-#{collection_name}" @reversed = p.id?('reversed') while p.look(:comma) || p.look(:id) p.consume?(:comma) unless (attribute = p.id?('limit') || p.id?('offset')) raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_attribute") end p.consume(:colon) set_attribute(attribute, p.expression, safe: true) end p.consume(:end_of_string) end private def strict2_parse(markup) strict_parse(markup) end def collection_segment(context) offsets = context.registers[:for] ||= {} from = if @from == :continue offsets[@name].to_i else from_value = context.evaluate(@from) if from_value.nil? 0 else Utils.to_integer(from_value) end end collection = context.evaluate(@collection_name) collection = collection.to_a if collection.is_a?(Range) limit_value = context.evaluate(@limit) to = if limit_value.nil? nil else Utils.to_integer(limit_value) + from end segment = Utils.slice_collection(collection, from, to) segment.reverse! if @reversed offsets[@name] = from + segment.length segment end def render_segment(context, output, segment) for_stack = context.registers[:for_stack] ||= [] length = segment.length context.stack do loop_vars = Liquid::ForloopDrop.new(@name, length, for_stack[-1]) for_stack.push(loop_vars) begin context['forloop'] = loop_vars segment.each do |item| context[@variable_name] = item @for_block.render_to_output_buffer(context, output) loop_vars.send(:increment!) # Handle any interrupts if they exist. next unless context.interrupt? interrupt = context.pop_interrupt break if interrupt.is_a?(BreakInterrupt) next if interrupt.is_a?(ContinueInterrupt) end ensure for_stack.pop end end output end def set_attribute(key, expr, safe: false) case key when 'offset' @from = if expr == 'continue' :continue else parse_expression(expr, safe: safe) end when 'limit' @limit = parse_expression(expr, safe: safe) end end def render_else(context, output) if @else_block @else_block.render_to_output_buffer(context, output) else output end end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children (super + [@node.limit, @node.from, @node.collection_name]).compact end end end end ================================================ FILE: lib/liquid/tags/if.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category conditional # @liquid_name if # @liquid_summary # Renders an expression if a specific condition is `true`. # @liquid_syntax # {% if condition %} # expression # {% endif %} # @liquid_syntax_keyword condition The condition to evaluate. # @liquid_syntax_keyword expression The expression to render if the condition is met. class If < Block Syntax = /(#{QuotedFragment})\s*([=!<>a-z_]+)?\s*(#{QuotedFragment})?/o ExpressionsAndOperators = /(?:\b(?:\s?and\s?|\s?or\s?)\b|(?:\s*(?!\b(?:\s?and\s?|\s?or\s?)\b)(?:#{QuotedFragment}|\S+)\s*)+)/o BOOLEAN_OPERATORS = %w(and or).freeze attr_reader :blocks def initialize(tag_name, markup, options) super @blocks = [] push_block('if', markup) end def nodelist @blocks.map(&:attachment) end def parse(tokens) while parse_body(@blocks.last.attachment, tokens) end @blocks.reverse_each do |block| block.attachment.remove_blank_strings if blank? block.attachment.freeze end end ELSE_TAG_NAMES = ['elsif', 'else'].freeze private_constant :ELSE_TAG_NAMES def unknown_tag(tag, markup, tokens) if ELSE_TAG_NAMES.include?(tag) push_block(tag, markup) else super end end def render_to_output_buffer(context, output) @blocks.each do |block| result = Liquid::Utils.to_liquid_value( block.evaluate(context), ) if result return block.attachment.render_to_output_buffer(context, output) end end output end private def strict2_parse(markup) strict_parse(markup) end def push_block(tag, markup) block = if tag == 'else' ElseCondition.new else parse_with_selected_parser(markup) end @blocks.push(block) block.attach(new_body) end def parse_expression(markup, safe: false) Condition.parse_expression(parse_context, markup, safe: safe) end def lax_parse(markup) expressions = markup.scan(ExpressionsAndOperators) raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop =~ Syntax condition = Condition.new(parse_expression(Regexp.last_match(1)), Regexp.last_match(2), parse_expression(Regexp.last_match(3))) until expressions.empty? operator = expressions.pop.to_s.strip raise SyntaxError, options[:locale].t("errors.syntax.if") unless expressions.pop.to_s =~ Syntax new_condition = Condition.new(parse_expression(Regexp.last_match(1)), Regexp.last_match(2), parse_expression(Regexp.last_match(3))) raise SyntaxError, options[:locale].t("errors.syntax.if") unless BOOLEAN_OPERATORS.include?(operator) new_condition.send(operator, condition) condition = new_condition end condition end def strict_parse(markup) p = @parse_context.new_parser(markup) condition = parse_binary_comparisons(p) p.consume(:end_of_string) condition end def parse_binary_comparisons(p) condition = parse_comparison(p) first_condition = condition while (op = p.id?('and') || p.id?('or')) child_condition = parse_comparison(p) condition.send(op, child_condition) condition = child_condition end first_condition end def parse_comparison(p) a = parse_expression(p.expression, safe: true) if (op = p.consume?(:comparison)) b = parse_expression(p.expression, safe: true) Condition.new(a, op, b) else Condition.new(a) end end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children @node.blocks end end end end ================================================ FILE: lib/liquid/tags/ifchanged.rb ================================================ # frozen_string_literal: true module Liquid class Ifchanged < Block def render_to_output_buffer(context, output) block_output = +'' super(context, block_output) if block_output != context.registers[:ifchanged] context.registers[:ifchanged] = block_output output << block_output end output end end end ================================================ FILE: lib/liquid/tags/include.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category theme # @liquid_name include # @liquid_summary # Renders a [snippet](/themes/architecture/snippets). # @liquid_description # Inside the snippet, you can access and alter variables that are [created](/docs/api/liquid/tags/variable-tags) outside of the # snippet. # @liquid_syntax # {% include 'filename' %} # @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension. # @liquid_deprecated # Deprecated because the way that variables are handled reduces performance and makes code harder to both read and maintain. # # The `include` tag has been replaced by [`render`](/docs/api/liquid/tags/render). class Include < Tag prepend Tag::Disableable SYNTAX = /(#{QuotedFragment}+)(\s+(?:with|for)\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o Syntax = SYNTAX attr_reader :template_name_expr, :variable_name_expr, :attributes def initialize(tag_name, markup, options) super parse_with_selected_parser(markup) end def parse(_tokens) end def render_to_output_buffer(context, output) template_name = context.evaluate(@template_name_expr) raise ArgumentError, options[:locale].t("errors.argument.include") unless template_name.is_a?(String) partial = PartialCache.load( template_name, context: context, parse_context: parse_context, ) context_variable_name = @alias_name || template_name.split('/').last variable = if @variable_name_expr context.evaluate(@variable_name_expr) else context.find_variable(template_name, raise_on_not_found: false) end old_template_name = context.template_name old_partial = context.partial begin context.template_name = partial.name context.partial = true context.stack do @attributes.each do |key, value| context[key] = context.evaluate(value) end if variable.is_a?(Array) variable.each do |var| context[context_variable_name] = var partial.render_to_output_buffer(context, output) end else context[context_variable_name] = variable partial.render_to_output_buffer(context, output) end end ensure context.template_name = old_template_name context.partial = old_partial end output end alias_method :parse_context, :options private :parse_context def strict2_parse(markup) p = @parse_context.new_parser(markup) @template_name_expr = safe_parse_expression(p) @variable_name_expr = safe_parse_expression(p) if p.id?("for") || p.id?("with") @alias_name = p.consume(:id) if p.id?("as") p.consume?(:comma) @attributes = {} while p.look(:id) key = p.consume p.consume(:colon) @attributes[key] = safe_parse_expression(p) p.consume?(:comma) end p.consume(:end_of_string) end def strict_parse(markup) lax_parse(markup) end def lax_parse(markup) if markup =~ SYNTAX template_name = Regexp.last_match(1) variable_name = Regexp.last_match(3) @alias_name = Regexp.last_match(5) @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @template_name_expr = parse_expression(template_name) @attributes = {} markup.scan(TagAttributes) do |key, value| @attributes[key] = parse_expression(value) end else raise SyntaxError, options[:locale].t("errors.syntax.include") end end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [ @node.template_name_expr, @node.variable_name_expr, ] + @node.attributes.values end end end end ================================================ FILE: lib/liquid/tags/increment.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category variable # @liquid_name increment # @liquid_summary # Creates a new variable, with a default value of 0, that's increased by 1 with each subsequent call. # # > Caution: # > Predefined Liquid objects can be overridden by variables with the same name. # > To make sure that you can access all Liquid objects, make sure that your variable name doesn't match a predefined object's name. # @liquid_description # Variables that are declared with `increment` are unique to the [layout](/themes/architecture/layouts), [template](/themes/architecture/templates), # or [section](/themes/architecture/sections) file that they're created in. However, the variable is shared across # [snippets](/themes/architecture/snippets) included in the file. # # Similarly, variables that are created with `increment` are independent from those created with [`assign`](/docs/api/liquid/tags/assign) # and [`capture`](/docs/api/liquid/tags/capture). However, `increment` and [`decrement`](/docs/api/liquid/tags/decrement) share # variables. # @liquid_syntax # {% increment variable_name %} # @liquid_syntax_keyword variable_name The name of the variable being incremented. class Increment < Tag attr_reader :variable_name def initialize(tag_name, markup, options) super @variable_name = markup.strip end def render_to_output_buffer(context, output) counter_environment = context.environments.first value = counter_environment[@variable_name] || 0 counter_environment[@variable_name] = value + 1 output << value.to_s output end end end ================================================ FILE: lib/liquid/tags/inline_comment.rb ================================================ # frozen_string_literal: true module Liquid class InlineComment < Tag def initialize(tag_name, markup, options) super # Semantically, a comment should only ignore everything after it on the line. # Currently, this implementation doesn't support mixing a comment with another tag # but we need to reserve future support for this and prevent the introduction # of inline comments from being backward incompatible change. # # As such, we're forcing users to put a # symbol on every line otherwise this # tag will throw an error. if markup.match?(/\n\s*[^#\s]/) raise SyntaxError, options[:locale].t("errors.syntax.inline_comment_invalid") end end def render_to_output_buffer(_context, output) output end def blank? true end end end ================================================ FILE: lib/liquid/tags/raw.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category syntax # @liquid_name raw # @liquid_summary # Outputs any Liquid code as text instead of rendering it. # @liquid_syntax # {% raw %} # expression # {% endraw %} # @liquid_syntax_keyword expression The expression to be output without being rendered. class Raw < Block Syntax = /\A\s*\z/ def initialize(tag_name, markup, parse_context) super ensure_valid_markup(tag_name, markup, parse_context) end def parse(tokens) @body = +'' while (token = tokens.shift) if token =~ BlockBody::FullTokenPossiblyInvalid && block_delimiter == Regexp.last_match(2) parse_context.trim_whitespace = (token[-3] == WhitespaceControl) @body << Regexp.last_match(1) if Regexp.last_match(1) != "" return end @body << token unless token.empty? end raise_tag_never_closed(block_name) end def render_to_output_buffer(_context, output) output << @body output end def nodelist [@body] end def blank? @body.empty? end protected def ensure_valid_markup(tag_name, markup, parse_context) unless Syntax.match?(markup) raise SyntaxError, parse_context.locale.t("errors.syntax.tag_unexpected_args", tag: tag_name) end end end end ================================================ FILE: lib/liquid/tags/render.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category theme # @liquid_name render # @liquid_summary # Renders a [snippet](/themes/architecture/snippets) or [app block](/themes/architecture/sections/section-schema#render-app-blocks). # @liquid_description # Inside snippets and app blocks, you can't directly access variables that are [created](/docs/api/liquid/tags/variable-tags) outside # of the snippet or app block. However, you can [specify variables as parameters](/docs/api/liquid/tags/render#render-passing-variables-to-a-snippet) # to pass outside variables to snippets. # # While you can't directly access created variables, you can access global objects, as well as any objects that are # directly accessible outside the snippet or app block. For example, a snippet or app block inside the [product template](/themes/architecture/templates/product) # can access the [`product` object](/docs/api/liquid/objects/product), and a snippet or app block inside a [section](/themes/architecture/sections) # can access the [`section` object](/docs/api/liquid/objects/section). # # Outside a snippet or app block, you can't access variables created inside the snippet or app block. # # > Note: # > When you render a snippet using the `render` tag, you can't use the [`include` tag](/docs/api/liquid/tags/include) # > inside the snippet. # @liquid_syntax # {% render 'filename' %} # @liquid_syntax_keyword filename The name of the snippet to render, without the `.liquid` extension. class Render < Tag FOR = 'for' SYNTAX = /(#{QuotedString}+)(\s+(with|#{FOR})\s+(#{QuotedFragment}+))?(\s+(?:as)\s+(#{VariableSegment}+))?/o disable_tags "include" attr_reader :template_name_expr, :variable_name_expr, :attributes, :alias_name def initialize(tag_name, markup, options) super parse_with_selected_parser(markup) end def for_loop? @is_for_loop end def render_to_output_buffer(context, output) render_tag(context, output) end def render_tag(context, output) # The expression should be a String literal, which parses to a String object template_name = @template_name_expr raise ::ArgumentError unless template_name.is_a?(String) partial = PartialCache.load( template_name, context: context, parse_context: parse_context, ) context_variable_name = @alias_name || template_name.split('/').last render_partial_func = ->(var, forloop) { inner_context = context.new_isolated_subcontext inner_context.template_name = partial.name inner_context.partial = true inner_context['forloop'] = forloop if forloop @attributes.each do |key, value| inner_context[key] = context.evaluate(value) end inner_context[context_variable_name] = var unless var.nil? partial.render_to_output_buffer(inner_context, output) forloop&.send(:increment!) } variable = @variable_name_expr ? context.evaluate(@variable_name_expr) : nil if @is_for_loop && variable.respond_to?(:each) && variable.respond_to?(:count) forloop = Liquid::ForloopDrop.new(template_name, variable.count, nil) variable.each { |var| render_partial_func.call(var, forloop) } else render_partial_func.call(variable, nil) end output end # render (string) (with|for expression)? (as id)? (key: value)* def strict2_parse(markup) p = @parse_context.new_parser(markup) @template_name_expr = parse_expression(strict2_template_name(p), safe: true) with_or_for = p.id?("for") || p.id?("with") @variable_name_expr = safe_parse_expression(p) if with_or_for @alias_name = p.consume(:id) if p.id?("as") @is_for_loop = (with_or_for == FOR) p.consume?(:comma) @attributes = {} while p.look(:id) key = p.consume p.consume(:colon) @attributes[key] = safe_parse_expression(p) p.consume?(:comma) end p.consume(:end_of_string) end def strict2_template_name(p) p.consume(:string) end def strict_parse(markup) lax_parse(markup) end def lax_parse(markup) raise SyntaxError, options[:locale].t("errors.syntax.render") unless markup =~ SYNTAX template_name = Regexp.last_match(1) with_or_for = Regexp.last_match(3) variable_name = Regexp.last_match(4) @alias_name = Regexp.last_match(6) @variable_name_expr = variable_name ? parse_expression(variable_name) : nil @template_name_expr = parse_expression(template_name) @is_for_loop = (with_or_for == FOR) @attributes = {} markup.scan(TagAttributes) do |key, value| @attributes[key] = parse_expression(value) end end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [ @node.template_name_expr, @node.variable_name_expr, ] + @node.attributes.values end end end end ================================================ FILE: lib/liquid/tags/table_row.rb ================================================ # frozen_string_literal: true module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category iteration # @liquid_name tablerow # @liquid_summary # Generates HTML table rows for every item in an array. # @liquid_description # The `tablerow` tag must be wrapped in HTML `` and `
` tags. # # > Tip: # > Every `tablerow` loop has an associated [`tablerowloop` object](/docs/api/liquid/objects/tablerowloop) with information about the loop. # @liquid_syntax # {% tablerow variable in array %} # expression # {% endtablerow %} # @liquid_syntax_keyword variable The current item in the array. # @liquid_syntax_keyword array The array to iterate over. # @liquid_syntax_keyword expression The expression to render. # @liquid_optional_param cols: [number] The number of columns that the table should have. # @liquid_optional_param limit: [number] The number of iterations to perform. # @liquid_optional_param offset: [number] The 1-based index to start iterating at. # @liquid_optional_param range [untyped] A custom numeric range to iterate over. class TableRow < Block Syntax = /(\w+)\s+in\s+(#{QuotedFragment}+)/o ALLOWED_ATTRIBUTES = ['cols', 'limit', 'offset', 'range'].freeze attr_reader :variable_name, :collection_name, :attributes def initialize(tag_name, markup, options) super parse_with_selected_parser(markup) end def strict2_parse(markup) p = @parse_context.new_parser(markup) @variable_name = p.consume(:id) unless p.id?("in") raise SyntaxError, options[:locale].t("errors.syntax.for_invalid_in") end @collection_name = safe_parse_expression(p) p.consume?(:comma) @attributes = {} while p.look(:id) key = p.consume unless ALLOWED_ATTRIBUTES.include?(key) raise SyntaxError, options[:locale].t("errors.syntax.table_row_invalid_attribute", attribute: key) end p.consume(:colon) @attributes[key] = safe_parse_expression(p) p.consume?(:comma) end p.consume(:end_of_string) end def strict_parse(markup) lax_parse(markup) end def lax_parse(markup) if markup =~ Syntax @variable_name = Regexp.last_match(1) @collection_name = parse_expression(Regexp.last_match(2)) @attributes = {} markup.scan(TagAttributes) do |key, value| @attributes[key] = parse_expression(value) end else raise SyntaxError, options[:locale].t("errors.syntax.table_row") end end def render_to_output_buffer(context, output) (collection = context.evaluate(@collection_name)) || (return '') from = @attributes.key?('offset') ? to_integer(context.evaluate(@attributes['offset'])) : 0 to = @attributes.key?('limit') ? from + to_integer(context.evaluate(@attributes['limit'])) : nil collection = Utils.slice_collection(collection, from, to) length = collection.length cols = @attributes.key?('cols') ? to_integer(context.evaluate(@attributes['cols'])) : length output << "\n" context.stack do tablerowloop = Liquid::TablerowloopDrop.new(length, cols) context['tablerowloop'] = tablerowloop collection.each do |item| context[@variable_name] = item output << "" super output << '' # Handle any interrupts if they exist. if context.interrupt? interrupt = context.pop_interrupt break if interrupt.is_a?(BreakInterrupt) end if tablerowloop.col_last && !tablerowloop.last output << "\n" end tablerowloop.send(:increment!) end end output << "\n" output end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children super + @node.attributes.values + [@node.collection_name] end end private def to_integer(value) value.to_i rescue NoMethodError raise Liquid::ArgumentError, "invalid integer" end end end ================================================ FILE: lib/liquid/tags/unless.rb ================================================ # frozen_string_literal: true require_relative 'if' module Liquid # @liquid_public_docs # @liquid_type tag # @liquid_category conditional # @liquid_name unless # @liquid_summary # Renders an expression unless a specific condition is `true`. # @liquid_description # > Tip: # > Similar to the [`if` tag](/docs/api/liquid/tags/if), you can use `elsif` to add more conditions to an `unless` tag. # @liquid_syntax # {% unless condition %} # expression # {% endunless %} # @liquid_syntax_keyword condition The condition to evaluate. # @liquid_syntax_keyword expression The expression to render unless the condition is met. class Unless < If def render_to_output_buffer(context, output) # First condition is interpreted backwards ( if not ) first_block = @blocks.first result = Liquid::Utils.to_liquid_value( first_block.evaluate(context), ) unless result return first_block.attachment.render_to_output_buffer(context, output) end # After the first condition unless works just like if @blocks[1..-1].each do |block| result = Liquid::Utils.to_liquid_value( block.evaluate(context), ) if result return block.attachment.render_to_output_buffer(context, output) end end output end end end ================================================ FILE: lib/liquid/tags.rb ================================================ # frozen_string_literal: true require_relative "tags/table_row" require_relative "tags/echo" require_relative "tags/if" require_relative "tags/break" require_relative "tags/inline_comment" require_relative "tags/for" require_relative "tags/assign" require_relative "tags/ifchanged" require_relative "tags/case" require_relative "tags/include" require_relative "tags/continue" require_relative "tags/capture" require_relative "tags/decrement" require_relative "tags/unless" require_relative "tags/increment" require_relative "tags/comment" require_relative "tags/raw" require_relative "tags/render" require_relative "tags/cycle" require_relative "tags/doc" module Liquid module Tags STANDARD_TAGS = { 'cycle' => Cycle, 'render' => Render, 'raw' => Raw, 'comment' => Comment, 'increment' => Increment, 'unless' => Unless, 'decrement' => Decrement, 'capture' => Capture, 'continue' => Continue, 'include' => Include, 'case' => Case, 'ifchanged' => Ifchanged, 'assign' => Assign, 'for' => For, '#' => InlineComment, 'break' => Break, 'if' => If, 'echo' => Echo, 'tablerow' => TableRow, 'doc' => Doc, }.freeze end end ================================================ FILE: lib/liquid/template.rb ================================================ # frozen_string_literal: true module Liquid # Templates are central to liquid. # Interpreting templates is a two step process. First you compile the # source code you got. During compile time some extensive error checking is performed. # your code should expect to get some SyntaxErrors. # # After you have a compiled template you can then render it. # You can use a compiled template over and over again and keep it cached. # # Example: # # template = Liquid::Template.parse(source) # template.render('user_name' => 'bob') # class Template attr_accessor :root, :name attr_reader :resource_limits, :warnings attr_reader :profiler class << self # Sets how strict the parser should be. # :lax acts like liquid 2.5 and silently ignores malformed tags in most cases. # :warn is the default and will give deprecation warnings when invalid syntax is used. # :strict enforces correct syntax for most tags # :strict2 enforces correct syntax for all tags def error_mode=(mode) Deprecations.warn("Template.error_mode=", "Environment#error_mode=") Environment.default.error_mode = mode end def error_mode Environment.default.error_mode end def default_exception_renderer=(renderer) Deprecations.warn("Template.default_exception_renderer=", "Environment#exception_renderer=") Environment.default.exception_renderer = renderer end def default_exception_renderer Environment.default.exception_renderer end def file_system=(file_system) Deprecations.warn("Template.file_system=", "Environment#file_system=") Environment.default.file_system = file_system end def file_system Environment.default.file_system end def tags Environment.default.tags end def register_tag(name, klass) Deprecations.warn("Template.register_tag", "Environment#register_tag") Environment.default.register_tag(name, klass) end # Pass a module with filter methods which should be available # to all liquid views. Good for registering the standard library def register_filter(mod) Deprecations.warn("Template.register_filter", "Environment#register_filter") Environment.default.register_filter(mod) end private def default_resource_limits=(limits) Deprecations.warn("Template.default_resource_limits=", "Environment#default_resource_limits=") Environment.default.default_resource_limits = limits end def default_resource_limits Environment.default.default_resource_limits end # creates a new Template object from liquid source code # To enable profiling, pass in profile: true as an option. # See Liquid::Profiler for more information def parse(source, options = {}) environment = options[:environment] || Environment.default new(environment: environment).parse(source, options) end end def initialize(environment: Environment.default) @environment = environment @rethrow_errors = false @resource_limits = ResourceLimits.new(environment.default_resource_limits) end # Parse source code. # Returns self for easy chaining def parse(source, options = {}) parse_context = configure_options(options) source = source.to_s.to_str unless source.valid_encoding? raise TemplateEncodingError, parse_context.locale.t("errors.syntax.invalid_template_encoding") end tokenizer = parse_context.new_tokenizer(source, start_line_number: @line_numbers && 1) @root = Document.parse(tokenizer, parse_context) self end def registers @registers ||= {} end def assigns @assigns ||= {} end def instance_assigns @instance_assigns ||= {} end def errors @errors ||= [] end # Render takes a hash with local variables. # # if you use the same filters over and over again consider registering them globally # with Template.register_filter # # if profiling was enabled in Template#parse then the resulting profiling information # will be available via Template#profiler # # Following options can be passed: # # * filters : array with local filters # * registers : hash with register variables. Those can be accessed from # filters and tags and might be useful to integrate liquid more with its host application # def render(*args) return '' if @root.nil? context = case args.first when Liquid::Context c = args.shift if @rethrow_errors c.exception_renderer = Liquid::RAISE_EXCEPTION_LAMBDA end c when Liquid::Drop drop = args.shift drop.context = Context.new([drop, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) when Hash Context.new([args.shift, assigns], instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) when nil Context.new(assigns, instance_assigns, registers, @rethrow_errors, @resource_limits, {}, @environment) else raise ArgumentError, "Expected Hash or Liquid::Context as parameter" end output = nil case args.last when Hash options = args.pop output = options[:output] if options[:output] static_registers = context.registers.static options[:registers]&.each do |key, register| static_registers[key] = register end apply_options_to_context(context, options) when Module, Array context.add_filters(args.pop) end # Retrying a render resets resource usage context.resource_limits.reset if @profiling && context.profiler.nil? @profiler = context.profiler = Liquid::Profiler.new end context.template_name ||= name begin # render the nodelist. @root.render_to_output_buffer(context, output || +'') rescue Liquid::MemoryError => e context.handle_error(e) ensure @errors = context.errors end end def render!(*args) @rethrow_errors = true render(*args) end def render_to_output_buffer(context, output) render(context, output: output) end private def configure_options(options) if (profiling = options[:profile]) raise "Profiler not loaded, require 'liquid/profiler' first" unless defined?(Liquid::Profiler) end @options = options @profiling = profiling @line_numbers = options[:line_numbers] || @profiling parse_context = if options.is_a?(ParseContext) options else opts = options.key?(:environment) ? options : options.merge(environment: @environment) ParseContext.new(opts) end @warnings = parse_context.warnings parse_context end def apply_options_to_context(context, options) context.add_filters(options[:filters]) if options[:filters] context.global_filter = options[:global_filter] if options[:global_filter] context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] context.strict_variables = options[:strict_variables] if options[:strict_variables] context.strict_filters = options[:strict_filters] if options[:strict_filters] end end end ================================================ FILE: lib/liquid/template_factory.rb ================================================ # frozen_string_literal: true module Liquid class TemplateFactory def for(_template_name) Liquid::Template.new end end end ================================================ FILE: lib/liquid/tokenizer.rb ================================================ # frozen_string_literal: true require "strscan" module Liquid class Tokenizer attr_reader :line_number, :for_liquid_tag TAG_END = /%\}/ TAG_OR_VARIABLE_START = /\{[\{\%]/ NEWLINE = /\n/ OPEN_CURLEY = "{".ord CLOSE_CURLEY = "}".ord PERCENTAGE = "%".ord def initialize( source:, string_scanner:, line_numbers: false, line_number: nil, for_liquid_tag: false ) @line_number = line_number || (line_numbers ? 1 : nil) @for_liquid_tag = for_liquid_tag @source = source.to_s.to_str @offset = 0 @tokens = [] if @source @ss = string_scanner @ss.string = @source tokenize end end def shift token = @tokens[@offset] return unless token @offset += 1 if @line_number @line_number += @for_liquid_tag ? 1 : token.count("\n") end token end private def tokenize if @for_liquid_tag @tokens = @source.split("\n") else @tokens << shift_normal until @ss.eos? end @source = nil @ss = nil end def shift_normal token = next_token return unless token token end def next_token # possible states: :text, :tag, :variable byte_a = @ss.peek_byte if byte_a == OPEN_CURLEY @ss.scan_byte byte_b = @ss.peek_byte if byte_b == PERCENTAGE @ss.scan_byte return next_tag_token elsif byte_b == OPEN_CURLEY @ss.scan_byte return next_variable_token end @ss.pos -= 1 end next_text_token end def next_text_token start = @ss.pos unless @ss.skip_until(TAG_OR_VARIABLE_START) token = @ss.rest @ss.terminate return token end pos = @ss.pos -= 2 @source.byteslice(start, pos - start) rescue ::ArgumentError => e if e.message == "invalid byte sequence in #{@ss.string.encoding}" raise SyntaxError, "Invalid byte sequence in #{@ss.string.encoding}" else raise end end def next_variable_token start = @ss.pos - 2 byte_a = byte_b = @ss.scan_byte while byte_b byte_a = @ss.scan_byte while byte_a && byte_a != CLOSE_CURLEY && byte_a != OPEN_CURLEY break unless byte_a if @ss.eos? return byte_a == CLOSE_CURLEY ? @source.byteslice(start, @ss.pos - start) : "{{" end byte_b = @ss.scan_byte if byte_a == CLOSE_CURLEY if byte_b == CLOSE_CURLEY return @source.byteslice(start, @ss.pos - start) elsif byte_b != CLOSE_CURLEY @ss.pos -= 1 return @source.byteslice(start, @ss.pos - start) end elsif byte_a == OPEN_CURLEY && byte_b == PERCENTAGE return next_tag_token_with_start(start) end byte_a = byte_b end "{{" end def next_tag_token start = @ss.pos - 2 if (len = @ss.skip_until(TAG_END)) @source.byteslice(start, len + 2) else "{%" end end def next_tag_token_with_start(start) @ss.skip_until(TAG_END) @source.byteslice(start, @ss.pos - start) end end end ================================================ FILE: lib/liquid/usage.rb ================================================ # frozen_string_literal: true module Liquid module Usage def self.increment(name) end end end ================================================ FILE: lib/liquid/utils.rb ================================================ # frozen_string_literal: true module Liquid module Utils DECIMAL_REGEX = /\A-?\d+\.\d+\z/ UNIX_TIMESTAMP_REGEX = /\A\d+\z/ def self.slice_collection(collection, from, to) if (from != 0 || !to.nil?) && collection.respond_to?(:load_slice) collection.load_slice(from, to) else slice_collection_using_each(collection, from, to) end end def self.slice_collection_using_each(collection, from, to) segments = [] index = 0 # Maintains Ruby 1.8.7 String#each behaviour on 1.9 if collection.is_a?(String) return collection.empty? ? [] : [collection] end return [] unless collection.respond_to?(:each) collection.each do |item| if to && to <= index break end if from <= index segments << item end index += 1 end segments end def self.to_integer(num) return num if num.is_a?(Integer) num = num.to_s begin Integer(num) rescue ::ArgumentError raise Liquid::ArgumentError, "invalid integer" end end def self.to_number(obj) case obj when Float BigDecimal(obj.to_s) when Numeric obj when String DECIMAL_REGEX.match?(obj.strip) ? BigDecimal(obj) : obj.to_i else if obj.respond_to?(:to_number) obj.to_number else 0 end end end def self.to_date(obj) return obj if obj.respond_to?(:strftime) if obj.is_a?(String) return if obj.empty? obj = obj.downcase end case obj when 'now', 'today' Time.now when UNIX_TIMESTAMP_REGEX, Integer Time.at(obj.to_i) when String Time.parse(obj) end rescue ::ArgumentError nil end def self.to_liquid_value(obj) # Enable "obj" to represent itself as a primitive value like integer, string, or boolean return obj.to_liquid_value if obj.respond_to?(:to_liquid_value) # Otherwise return the object itself obj end def self.to_s(obj, seen = {}) case obj when BigDecimal obj.to_s("F") when Hash # If the custom hash implementation overrides `#to_s`, use their # custom implementation. Otherwise we use Liquid's default # implementation. if obj.class.instance_method(:to_s) == HASH_TO_S_METHOD hash_inspect(obj, seen) else obj.to_s end when Array array_inspect(obj, seen) else obj.to_s end end def self.inspect(obj, seen = {}) case obj when Hash # If the custom hash implementation overrides `#inspect`, use their # custom implementation. Otherwise we use Liquid's default # implementation. if obj.class.instance_method(:inspect) == HASH_INSPECT_METHOD hash_inspect(obj, seen) else obj.inspect end when Array array_inspect(obj, seen) else obj.inspect end end def self.array_inspect(arr, seen = {}) if seen[arr.object_id] return "[...]" end seen[arr.object_id] = true str = +"[" cursor = 0 len = arr.length while cursor < len if cursor > 0 str << ", " end item_str = inspect(arr[cursor], seen) str << item_str cursor += 1 end str << "]" str ensure seen.delete(arr.object_id) end def self.hash_inspect(hash, seen = {}) if seen[hash.object_id] return "{...}" end seen[hash.object_id] = true str = +"{" first = true hash.each do |key, value| if first first = false else str << ", " end key_str = inspect(key, seen) str << key_str str << "=>" value_str = inspect(value, seen) str << value_str end str << "}" str ensure seen.delete(hash.object_id) end HASH_TO_S_METHOD = Hash.instance_method(:to_s) private_constant :HASH_TO_S_METHOD HASH_INSPECT_METHOD = Hash.instance_method(:inspect) private_constant :HASH_INSPECT_METHOD end end ================================================ FILE: lib/liquid/variable.rb ================================================ # frozen_string_literal: true module Liquid # Holds variables. Variables are only loaded "just in time" # and are not evaluated as part of the render stage # # {{ monkey }} # {{ user.name }} # # Variables can be combined with filters: # # {{ user | link }} # class Variable FilterMarkupRegex = /#{FilterSeparator}\s*(.*)/om FilterParser = /(?:\s+|#{QuotedFragment}|#{ArgumentSeparator})+/o FilterArgsRegex = /(?:#{FilterArgumentSeparator}|#{ArgumentSeparator})\s*((?:\w+\s*\:\s*)?#{QuotedFragment})/o JustTagAttributes = /\A#{TagAttributes}\z/o MarkupWithQuotedFragment = /(#{QuotedFragment})(.*)/om attr_accessor :filters, :name, :line_number attr_reader :parse_context alias_method :options, :parse_context include ParserSwitching def initialize(markup, parse_context) @markup = markup @name = nil @parse_context = parse_context @line_number = parse_context.line_number strict_parse_with_error_mode_fallback(markup) end def raw @markup end def markup_context(markup) "in \"{{#{markup}}}\"" end def lax_parse(markup) @filters = [] return unless markup =~ MarkupWithQuotedFragment name_markup = Regexp.last_match(1) filter_markup = Regexp.last_match(2) @name = parse_context.parse_expression(name_markup) if filter_markup =~ FilterMarkupRegex filters = Regexp.last_match(1).scan(FilterParser) filters.each do |f| next unless f =~ /\w+/ filtername = Regexp.last_match(0) filterargs = f.scan(FilterArgsRegex).flatten @filters << lax_parse_filter_expressions(filtername, filterargs) end end end def strict_parse(markup) @filters = [] p = @parse_context.new_parser(markup) return if p.look(:end_of_string) @name = parse_context.safe_parse_expression(p) while p.consume?(:pipe) filtername = p.consume(:id) filterargs = p.consume?(:colon) ? parse_filterargs(p) : Const::EMPTY_ARRAY @filters << lax_parse_filter_expressions(filtername, filterargs) end p.consume(:end_of_string) end def strict2_parse(markup) @filters = [] p = @parse_context.new_parser(markup) return if p.look(:end_of_string) @name = parse_context.safe_parse_expression(p) @filters << strict2_parse_filter_expressions(p) while p.consume?(:pipe) p.consume(:end_of_string) end def parse_filterargs(p) # first argument filterargs = [p.argument] # followed by comma separated others filterargs << p.argument while p.consume?(:comma) filterargs end def render(context) obj = context.evaluate(@name) @filters.each do |filter_name, filter_args, filter_kwargs| filter_args = evaluate_filter_expressions(context, filter_args, filter_kwargs) obj = context.invoke(filter_name, obj, *filter_args) end context.apply_global_filter(obj) end def render_to_output_buffer(context, output) obj = render(context) render_obj_to_output(obj, output) output end def render_obj_to_output(obj, output) case obj when NilClass # Do nothing when Array obj.each do |o| render_obj_to_output(o, output) end else output << Liquid::Utils.to_s(obj) end end def disabled?(_context) false end def disabled_tags [] end private def lax_parse_filter_expressions(filter_name, unparsed_args) filter_args = [] keyword_args = nil unparsed_args.each do |a| if (matches = a.match(JustTagAttributes)) keyword_args ||= {} keyword_args[matches[1]] = parse_context.parse_expression(matches[2]) else filter_args << parse_context.parse_expression(a) end end result = [filter_name, filter_args] result << keyword_args if keyword_args result end # Surprisingly, positional and keyword arguments can be mixed. # # filter = filtername [":" filterargs?] # filterargs = argument ("," argument)* # argument = (positional_argument | keyword_argument) # positional_argument = expression # keyword_argument = id ":" expression def strict2_parse_filter_expressions(p) filtername = p.consume(:id) filter_args = [] keyword_args = {} if p.consume?(:colon) # Parse first argument (no leading comma) argument(p, filter_args, keyword_args) unless end_of_arguments?(p) # Parse remaining arguments (with leading commas) and optional trailing comma argument(p, filter_args, keyword_args) while p.consume?(:comma) && !end_of_arguments?(p) end result = [filtername, filter_args] result << keyword_args unless keyword_args.empty? result end def argument(p, positional_arguments, keyword_arguments) if p.look(:id) && p.look(:colon, 1) key = p.consume(:id) p.consume(:colon) value = parse_context.safe_parse_expression(p) keyword_arguments[key] = value else positional_arguments << parse_context.safe_parse_expression(p) end end def end_of_arguments?(p) p.look(:pipe) || p.look(:end_of_string) end def evaluate_filter_expressions(context, filter_args, filter_kwargs) parsed_args = filter_args.map { |expr| context.evaluate(expr) } if filter_kwargs parsed_kwargs = {} filter_kwargs.each do |key, expr| parsed_kwargs[key] = context.evaluate(expr) end parsed_args << parsed_kwargs end parsed_args end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children [@node.name] + @node.filters.flatten end end end end ================================================ FILE: lib/liquid/variable_lookup.rb ================================================ # frozen_string_literal: true module Liquid class VariableLookup COMMAND_METHODS = ['size', 'first', 'last'].freeze attr_reader :name, :lookups def self.parse(markup, string_scanner = StringScanner.new(""), cache = nil) new(markup, string_scanner, cache) end def initialize(markup, string_scanner = StringScanner.new(""), cache = nil) lookups = markup.scan(VariableParser) name = lookups.shift if name&.start_with?('[') && name&.end_with?(']') name = Expression.parse( name[1..-2], string_scanner, cache, ) end @name = name @lookups = lookups @command_flags = 0 @lookups.each_index do |i| lookup = lookups[i] if lookup&.start_with?('[') && lookup&.end_with?(']') lookups[i] = Expression.parse( lookup[1..-2], string_scanner, cache, ) elsif COMMAND_METHODS.include?(lookup) @command_flags |= 1 << i end end end def lookup_command?(lookup_index) @command_flags & (1 << lookup_index) != 0 end def evaluate(context) name = context.evaluate(@name) object = context.find_variable(name) @lookups.each_index do |i| key = context.evaluate(@lookups[i]) # Cast "key" to its liquid value to enable it to act as a primitive value key = Liquid::Utils.to_liquid_value(key) # If object is a hash- or array-like object we look for the # presence of the key and if its available we return it if object.respond_to?(:[]) && ((object.respond_to?(:key?) && object.key?(key)) || (object.respond_to?(:fetch) && key.is_a?(Integer))) # if its a proc we will replace the entry with the proc res = context.lookup_and_evaluate(object, key) object = res.to_liquid # Some special cases. If the part wasn't in square brackets and # no key with the same name was found we interpret following calls # as commands and call them on the current object elsif lookup_command?(i) && object.respond_to?(key) object = object.send(key).to_liquid # Handle string first/last like ActiveSupport does (returns first/last character) # ActiveSupport returns "" for empty strings, not nil elsif lookup_command?(i) && object.is_a?(String) && (key == "first" || key == "last") object = key == "first" ? (object[0] || "") : (object[-1] || "") # No key was present with the desired value and it wasn't one of the directly supported # keywords either. The only thing we got left is to return nil or # raise an exception if `strict_variables` option is set to true else return nil unless context.strict_variables raise Liquid::UndefinedVariable, "undefined variable #{key}" end # If we are dealing with a drop here we have to object.context = context if object.respond_to?(:context=) end object end def ==(other) self.class == other.class && state == other.state end protected def state [@name, @lookups, @command_flags] end class ParseTreeVisitor < Liquid::ParseTreeVisitor def children @node.lookups end end end end ================================================ FILE: lib/liquid/version.rb ================================================ # encoding: utf-8 # frozen_string_literal: true module Liquid VERSION = "5.12.0" end ================================================ FILE: lib/liquid.rb ================================================ # frozen_string_literal: true # Copyright (c) 2005 Tobias Luetke # # Permission is hereby granted, free of charge, to any person obtaining # a copy of this software and associated documentation files (the # "Software"), to deal in the Software without restriction, including # without limitation the rights to use, copy, modify, merge, publish, # distribute, sublicense, and/or sell copies of the Software, and to # permit persons to whom the Software is furnished to do so, subject to # the following conditions: # # The above copyright notice and this permission notice shall be # included in all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. require "strscan" module Liquid FilterSeparator = /\|/ ArgumentSeparator = ',' FilterArgumentSeparator = ':' VariableAttributeSeparator = '.' WhitespaceControl = '-' TagStart = /\{\%/ TagEnd = /\%\}/ TagName = /#|\w+/ VariableSignature = /\(?[\w\-\.\[\]]\)?/ VariableSegment = /[\w\-]/ VariableStart = /\{\{/ VariableEnd = /\}\}/ VariableIncompleteEnd = /\}\}?/ QuotedString = /"[^"]*"|'[^']*'/ QuotedFragment = /#{QuotedString}|(?:[^\s,\|'"]|#{QuotedString})+/o TagAttributes = /(\w[\w-]*)\s*\:\s*(#{QuotedFragment})/o AnyStartingTag = /#{TagStart}|#{VariableStart}/o PartialTemplateParser = /#{TagStart}.*?#{TagEnd}|#{VariableStart}.*?#{VariableIncompleteEnd}/om TemplateParser = /(#{PartialTemplateParser}|#{AnyStartingTag})/om VariableParser = /\[(?>[^\[\]]+|\g<0>)*\]|#{VariableSegment}+\??/o RAISE_EXCEPTION_LAMBDA = ->(_e) { raise } HAS_STRING_SCANNER_SCAN_BYTE = StringScanner.instance_methods.include?(:scan_byte) end require "liquid/version" require "liquid/deprecations" require "liquid/const" require 'liquid/standardfilters' require 'liquid/file_system' require 'liquid/parser_switching' require 'liquid/tag' require 'liquid/block' require 'liquid/parse_tree_visitor' require 'liquid/interrupts' require 'liquid/tags' require "liquid/environment" require 'liquid/lexer' require 'liquid/parser' require 'liquid/i18n' require 'liquid/drop' require 'liquid/tablerowloop_drop' require 'liquid/forloop_drop' require 'liquid/extensions' require 'liquid/errors' require 'liquid/interrupts' require 'liquid/strainer_template' require 'liquid/context' require 'liquid/tag' require 'liquid/block_body' require 'liquid/document' require 'liquid/variable' require 'liquid/variable_lookup' require 'liquid/range_lookup' require 'liquid/resource_limits' require 'liquid/expression' require 'liquid/template' require 'liquid/condition' require 'liquid/utils' require 'liquid/tokenizer' require 'liquid/parse_context' require 'liquid/partial_cache' require 'liquid/usage' require 'liquid/registers' require 'liquid/template_factory' ================================================ FILE: liquid.gemspec ================================================ # encoding: utf-8 # frozen_string_literal: true lib = File.expand_path('../lib/', __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "liquid/version" Gem::Specification.new do |s| s.name = "liquid" s.version = Liquid::VERSION s.platform = Gem::Platform::RUBY s.summary = "A secure, non-evaling end user template engine with aesthetic markup." s.authors = ["Tobias Lütke"] s.email = ["tobi@leetsoft.com"] s.homepage = "https://shopify.github.io/liquid/" s.license = "MIT" # s.description = "A secure, non-evaling end user template engine with aesthetic markup." s.required_ruby_version = ">= 3.0.0" s.required_rubygems_version = ">= 1.3.7" s.metadata['allowed_push_host'] = 'https://rubygems.org' s.files = Dir.glob("{lib}/**/*") + %w(LICENSE README.md) s.extra_rdoc_files = ["History.md", "README.md"] s.require_path = "lib" s.add_dependency("strscan", ">= 3.1.1") s.add_dependency("bigdecimal") s.add_development_dependency('rake', '~> 13.0') s.add_development_dependency('minitest') end ================================================ FILE: performance/benchmark.rb ================================================ # frozen_string_literal: true require 'benchmark/ips' require_relative 'theme_runner' RubyVM::YJIT.enable if defined?(RubyVM::YJIT) Liquid::Environment.default.error_mode = ARGV.first.to_sym if ARGV.first profiler = ThemeRunner.new Benchmark.ips do |x| x.time = 20 x.warmup = 10 puts puts "Running benchmark for #{x.time} seconds (with #{x.warmup} seconds warmup)." puts phase = ENV["PHASE"] || "all" x.report("tokenize:") { profiler.tokenize } if phase == "all" || phase == "tokenize" x.report("parse:") { profiler.compile } if phase == "all" || phase == "parse" x.report("render:") { profiler.render } if phase == "all" || phase == "render" x.report("parse & render:") { profiler.run } if phase == "all" || phase == "run" end ================================================ FILE: performance/memory_profile.rb ================================================ # frozen_string_literal: true require 'benchmark/ips' require 'memory_profiler' require 'terminal-table' require_relative 'theme_runner' class Profiler LOG_LABEL = "Profiling: ".rjust(14).freeze REPORTS_DIR = File.expand_path('.memprof', __dir__).freeze def self.run puts yield new end def initialize @allocated = [] @retained = [] @headings = [] end def profile(phase, &block) print(LOG_LABEL) print("#{phase}.. ".ljust(10)) report = MemoryProfiler.report(&block) puts 'Done.' @headings << phase.capitalize @allocated << "#{report.scale_bytes(report.total_allocated_memsize)} (#{report.total_allocated} objects)" @retained << "#{report.scale_bytes(report.total_retained_memsize)} (#{report.total_retained} objects)" return if ENV['CI'] require 'fileutils' report_file = File.join(REPORTS_DIR, "#{sanitize(phase)}.txt") FileUtils.mkdir_p(REPORTS_DIR) report.pretty_print(to_file: report_file, scale_bytes: true) end def tabulate table = Terminal::Table.new(headings: @headings.unshift('Phase')) do |t| t << @allocated.unshift('Total allocated') t << @retained.unshift('Total retained') end puts puts table puts "\nDetailed report(s) saved to #{REPORTS_DIR}/" unless ENV['CI'] end def sanitize(string) string.downcase.gsub(/[\W]/, '-').squeeze('-') end end Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first runner = ThemeRunner.new Profiler.run do |x| x.profile('parse') { runner.compile } x.profile('render') { runner.render } x.tabulate end ================================================ FILE: performance/profile.rb ================================================ # frozen_string_literal: true require 'stackprof' require_relative 'theme_runner' Liquid::Template.error_mode = ARGV.first.to_sym if ARGV.first profiler = ThemeRunner.new profiler.run [:cpu, :object].each do |profile_type| puts "Profiling in #{profile_type} mode..." results = StackProf.run(mode: profile_type) do 200.times do profiler.run end end if profile_type == :cpu && (graph_filename = ENV['GRAPH_FILENAME']) File.open(graph_filename, 'w') do |f| StackProf::Report.new(results).print_graphviz(nil, f) end end StackProf::Report.new(results).print_text(false, 20) File.write(ENV['FILENAME'] + "." + profile_type.to_s, Marshal.dump(results)) if ENV['FILENAME'] end ================================================ FILE: performance/shopify/comment_form.rb ================================================ # frozen_string_literal: true class CommentForm < Liquid::Block Syntax = /(#{Liquid::VariableSignature}+)/ def initialize(tag_name, markup, options) super if markup =~ Syntax @variable_name = Regexp.last_match(1) @attributes = {} else raise SyntaxError, "Syntax Error in 'comment_form' - Valid syntax: comment_form [article]" end end def render_to_output_buffer(context, output) article = context[@variable_name] context.stack do context['form'] = { 'posted_successfully?' => context.registers[:posted_successfully], 'errors' => context['comment.errors'], 'author' => context['comment.author'], 'email' => context['comment.email'], 'body' => context['comment.body'], } output << wrap_in_form(article, render_all(@nodelist, context, output)) output end end def wrap_in_form(article, input) %(
\n#{input}\n
) end end ================================================ FILE: performance/shopify/database.rb ================================================ # frozen_string_literal: true require 'yaml' module Database DATABASE_FILE_PATH = "#{__dir__}/vision.database.yml" # Load the standard vision toolkit database and re-arrage it to be simply exportable # to liquid as assigns. All this is based on Shopify def self.tables @tables ||= begin db = if YAML.respond_to?(:unsafe_load_file) # Only Psych 4+ can use unsafe_load_file # unsafe_load_file is needed for YAML references YAML.unsafe_load_file(DATABASE_FILE_PATH) else YAML.load_file(DATABASE_FILE_PATH) end # From vision source db['products'].each do |product| collections = db['collections'].find_all do |collection| collection['products'].any? { |p| p['id'].to_i == product['id'].to_i } end product['collections'] = collections end # key the tables by handles, as this is how liquid expects it. db = db.each_with_object({}) do |(key, values), assigns| assigns[key] = values.each_with_object({}) do |v, h| h[v['handle']] = v end end # Some standard direct accessors so that the specialized templates # render correctly db['collection'] = db['collections'].values.first db['product'] = db['products'].values.first db['blog'] = db['blogs'].values.first db['article'] = db['blog']['articles'].first db['cart'] = { 'total_price' => db['line_items'].values.inject(0) { |sum, item| sum + item['line_price'] * item['quantity'] }, 'item_count' => db['line_items'].values.inject(0) { |sum, item| sum + item['quantity'] }, 'items' => db['line_items'].values, } db end end end ================================================ FILE: performance/shopify/json_filter.rb ================================================ # frozen_string_literal: true require 'json' module JsonFilter def json(object) JSON.dump(object.reject { |k, _v| k == "collections" }) end end ================================================ FILE: performance/shopify/liquid.rb ================================================ # frozen_string_literal: true $LOAD_PATH.unshift(__dir__ + '/../../lib') require_relative '../../lib/liquid' require_relative 'comment_form' require_relative 'paginate' require_relative 'json_filter' require_relative 'money_filter' require_relative 'shop_filter' require_relative 'tag_filter' require_relative 'weight_filter' default_environment = Liquid::Environment.default default_environment.register_tag('paginate', Paginate) default_environment.register_tag('form', CommentForm) default_environment.register_filter(JsonFilter) default_environment.register_filter(MoneyFilter) default_environment.register_filter(WeightFilter) default_environment.register_filter(ShopFilter) default_environment.register_filter(TagFilter) ================================================ FILE: performance/shopify/money_filter.rb ================================================ # frozen_string_literal: true module MoneyFilter def money_with_currency(money) return '' if money.nil? format("$ %.2f USD", money / 100.0) end def money(money) return '' if money.nil? format("$ %.2f", money / 100.0) end private def currency ShopDrop.new.currency end end ================================================ FILE: performance/shopify/paginate.rb ================================================ # frozen_string_literal: true class Paginate < Liquid::Block Syntax = /(#{Liquid::QuotedFragment})\s*(by\s*(\d+))?/ def initialize(tag_name, markup, options) super if markup =~ Syntax @collection_name = Regexp.last_match(1) @page_size = if Regexp.last_match(2) Regexp.last_match(3).to_i else 20 end @attributes = { 'window_size' => 3 } markup.scan(Liquid::TagAttributes) do |key, value| @attributes[key] = value end else raise SyntaxError, "Syntax Error in tag 'paginate' - Valid syntax: paginate [collection] by number" end end def render_to_output_buffer(context, output) @context = context context.stack do current_page = context['current_page'].to_i pagination = { 'page_size' => @page_size, 'current_page' => 5, 'current_offset' => @page_size * 5, } context['paginate'] = pagination collection_size = context[@collection_name].size raise ArgumentError, "Cannot paginate array '#{@collection_name}'. Not found." if collection_size.nil? page_count = (collection_size.to_f / @page_size.to_f).to_f.ceil + 1 pagination['items'] = collection_size pagination['pages'] = page_count - 1 pagination['previous'] = link('« Previous', current_page - 1) if 1 < current_page pagination['next'] = link('Next »', current_page + 1) if page_count > current_page + 1 pagination['parts'] = [] hellip_break = false if page_count > 2 1.upto(page_count - 1) do |page| if current_page == page pagination['parts'] << no_link(page) elsif page == 1 pagination['parts'] << link(page, page) elsif page == page_count - 1 pagination['parts'] << link(page, page) elsif page <= current_page - @attributes['window_size'] || page >= current_page + @attributes['window_size'] next if hellip_break pagination['parts'] << no_link('…') hellip_break = true next else pagination['parts'] << link(page, page) end hellip_break = false end end super end end private def no_link(title) { 'title' => title, 'is_link' => false } end def link(title, page) { 'title' => title, 'url' => current_url + "?page=#{page}", 'is_link' => true } end def current_url "/collections/frontpage" end end ================================================ FILE: performance/shopify/shop_filter.rb ================================================ # frozen_string_literal: true module ShopFilter def asset_url(input) "/files/1/[shop_id]/[shop_id]/assets/#{input}" end def global_asset_url(input) "/global/#{input}" end def shopify_asset_url(input) "/shopify/#{input}" end def script_tag(url) %() end def stylesheet_tag(url, media = "all") %() end def link_to(link, url, title = "") %(#{link}) end def img_tag(url, alt = "") %(#{alt}) end def link_to_vendor(vendor) if vendor link_to(vendor, url_for_vendor(vendor), vendor) else 'Unknown Vendor' end end def link_to_type(type) if type link_to(type, url_for_type(type), type) else 'Unknown Vendor' end end def url_for_vendor(vendor_title) "/collections/#{to_handle(vendor_title)}" end def url_for_type(type_title) "/collections/#{to_handle(type_title)}" end def product_img_url(url, style = 'small') unless url =~ %r{\Aproducts/([\w\-\_]+)\.(\w{2,4})} raise ArgumentError, 'filter "size" can only be called on product images' end case style when 'original' '/files/shops/random_number/' + url when 'grande', 'large', 'medium', 'compact', 'small', 'thumb', 'icon' "/files/shops/random_number/products/#{Regexp.last_match(1)}_#{style}.#{Regexp.last_match(2)}" else raise ArgumentError, 'valid parameters for filter "size" are: original, grande, large, medium, compact, small, thumb and icon ' end end def default_pagination(paginate) html = [] html << %(#{link_to(paginate['previous']['title'], paginate['previous']['url'])}) if paginate['previous'] paginate['parts'].each do |part| html << if part['is_link'] %(#{link_to(part['title'], part['url'])}) elsif part['title'].to_i == paginate['current_page'].to_i %(#{part['title']}) else %(#{part['title']}) end end html << %(#{link_to(paginate['next']['title'], paginate['next']['url'])}) if paginate['next'] html.join(' ') end # Accepts a number, and two words - one for singular, one for plural # Returns the singular word if input equals 1, otherwise plural def pluralize(input, singular, plural) input == 1 ? singular : plural end private def to_handle(str) result = str.dup result.downcase! result.delete!("'\"()[]") result.gsub!(/\W+/, '-') result.gsub!(/-+\z/, '') if result[-1] == '-' result.gsub!(/\A-+/, '') if result[0] == '-' result end end ================================================ FILE: performance/shopify/tag_filter.rb ================================================ # frozen_string_literal: true module TagFilter def link_to_tag(label, tag) "#{label}" end def highlight_active_tag(tag, css_class = 'active') if @context['current_tags'].include?(tag) "#{tag}" else tag end end def link_to_add_tag(label, tag) tags = (@context['current_tags'] + [tag]).uniq "#{label}" end def link_to_remove_tag(label, tag) tags = (@context['current_tags'] - [tag]).uniq "#{label}" end end ================================================ FILE: performance/shopify/vision.database.yml ================================================ # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # Variants # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- product_variants: - &product-1-var-1 id: 1 title: 151cm / Normal price: 19900 weight: 1000 compare_at_price: 49900 available: true inventory_quantity: 5 option1: 151cm option2: Normal option3: - &product-1-var-2 id: 2 title: 155cm / Normal price: 31900 weight: 1000 compare_at_price: 50900 available: true inventory_quantity: 2 option1: 155cm option2: Normal option3: - &product-2-var-1 id: 3 title: 162cm price: 29900 weight: 1000 compare_at_price: 52900 available: true inventory_quantity: 3 option1: 162cm option2: option3: - &product-3-var-1 id: 4 title: 159cm price: 19900 weight: 1000 compare_at_price: available: true inventory_quantity: 4 option1: 159cm option2: option3: - &product-4-var-1 id: 5 title: 159cm price: 19900 weight: 1000 compare_at_price: 32900 available: true inventory_quantity: 6 option1: 159cm option2: option3: - &product-1-var-3 id: 6 title: 158cm / Wide price: 23900 weight: 1000 compare_at_price: 99900 available: false inventory_quantity: 0 option1: 158cm option2: Wide option3: - &product-3-var-2 id: 7 title: 162cm price: 19900 weight: 1000 compare_at_price: available: false inventory_quantity: 0 option1: 162cm option2: option3: - &product-3-var-3 id: 8 title: 165cm price: 22900 weight: 1000 compare_at_price: available: true inventory_quantity: 4 option1: 165cm option2: option3: - &product-5-var-1 id: 9 title: black / 42 price: 11900 weight: 500 compare_at_price: 22900 available: true inventory_quantity: 1 option1: black option2: 42 option3: - &product-5-var-2 id: 10 title: beige / 42 price: 11900 weight: 500 compare_at_price: 22900 available: true inventory_quantity: 3 option1: beige option2: 42 option3: - &product-5-var-3 id: 11 title: white / 42 price: 13900 weight: 500 compare_at_price: 24900 available: true inventory_quantity: 1 option1: white option2: 42 option3: - &product-5-var-4 id: 12 title: black / 44 price: 11900 weight: 500 compare_at_price: 22900 available: true inventory_quantity: 2 option1: black option2: 44 option3: - &product-5-var-5 id: 13 title: beige / 44 price: 11900 weight: 500 compare_at_price: 22900 available: false inventory_quantity: 0 option1: beige option2: 44 option3: - &product-5-var-6 id: 14 title: white / 44 price: 13900 weight: 500 compare_at_price: 24900 available: false inventory_quantity: 0 option1: white option2: 44 option3: - &product-6-var-1 id: 15 title: red price: 2179500 weight: 200000 compare_at_price: available: true inventory_quantity: 0 option1: red option2: option3: - &product-7-var-1 id: 16 title: black / small price: 1900 weight: 200 compare_at_price: available: true inventory_quantity: 20 option1: black option2: small option3: - &product-7-var-2 id: 17 title: black / medium price: 1900 weight: 200 compare_at_price: available: false inventory_quantity: 0 option1: black option2: medium option3: - &product-7-var-3 id: 18 title: black / large price: 1900 weight: 200 compare_at_price: available: true inventory_quantity: 10 option1: black option2: large option3: - &product-7-var-4 id: 19 title: black / extra large price: 1900 weight: 200 compare_at_price: available: false inventory_quantity: 0 option1: black option2: extra large option3: - &product-8-var-1 id: 20 title: brown / small price: 5900 weight: 400 compare_at_price: 6900 available: true inventory_quantity: 5 option1: brown option2: small option3: - &product-8-var-2 id: 21 title: brown / medium price: 5900 weight: 400 compare_at_price: 6900 available: false inventory_quantity: 0 option1: brown option2: medium option3: - &product-8-var-3 id: 22 title: brown / large price: 5900 weight: 400 compare_at_price: 6900 available: true inventory_quantity: 10 option1: brown option2: large option3: - &product-8-var-4 id: 23 title: black / small price: 5900 weight: 400 compare_at_price: 6900 available: true inventory_quantity: 10 option1: black option2: small option3: - &product-8-var-5 id: 24 title: black / medium price: 5900 weight: 400 compare_at_price: 6900 available: true inventory_quantity: 10 option1: black option2: medium option3: - &product-8-var-6 id: 25 title: black / large price: 5900 weight: 400 compare_at_price: 6900 available: false inventory_quantity: 0 option1: black option2: large option3: - &product-9-var-1 id: 26 title: Body Only price: 499995 weight: 2000 compare_at_price: available: true inventory_quantity: 3 option1: Body Only option2: option3: - &product-9-var-2 id: 27 title: Kit with 18-55mm VR lens price: 523995 weight: 2000 compare_at_price: available: true inventory_quantity: 2 option1: Kit with 18-55mm VR lens option2: option3: - &product-9-var-3 id: 28 title: Kit with 18-200 VR lens price: 552500 weight: 2000 compare_at_price: available: true inventory_quantity: 3 option1: Kit with 18-200 VR lens option2: option3: # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # Products # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- products: - &product-1 id: 1 title: Arbor Draft handle: arbor-draft type: Snowboards vendor: Arbor price: 23900 price_max: 31900 price_min: 23900 price_varies: true available: true tags: - season2005 - pro - intermediate - wooden - freestyle options: - Length - Style compare_at_price: 49900 compare_at_price_max: 50900 compare_at_price_min: 49900 compare_at_price_varies: true url: /products/arbor-draft featured_image: products/arbor_draft.jpg images: - products/arbor_draft.jpg description: The Arbor Draft snowboard wouldn't exist if Polynesians hadn't figured out how to surf hundreds of years ago. But the Draft does exist, and it's here to bring your urban and park riding to a new level. The board's freaky Tiki design pays homage to culture that inspired snowboarding. It's designed to spin with ease, land smoothly, lock hook-free onto rails, and take the abuse of a pavement pounding or twelve. The Draft will pop off kickers with authority and carve solidly across the pipe. The Draft features targeted Koa wood die cuts inlayed into the deck that enhance the flex pattern. Now bow down to riding's ancestors. variants: - *product-1-var-1 - *product-1-var-2 - *product-1-var-3 - &product-2 id: 2 title: Arbor Element handle: arbor-element type: Snowboards vendor: Arbor price: 29900 price_max: 29900 price_min: 29900 price_varies: false available: true tags: - season2005 - pro - wooden - freestyle options: - Length compare_at_price: 52900 compare_at_price_max: 52900 compare_at_price_min: 52900 compare_at_price_varies: false url: /products/arbor-element featured_image: products/element58.jpg images: - products/element58.jpg description: The Element is a technically advanced all-mountain board for riders who readily transition from one terrain, snow condition, or riding style to another. Its balanced design provides the versatility needed for the true ride-it-all experience. The Element is exceedingly lively, freely initiates, and holds a tight edge at speed. Its structural real-wood topsheet is made with book-matched Koa. variants: - *product-2-var-1 - &product-3 id: 3 title: Comic ~ Pastel handle: comic-pastel type: Snowboards vendor: Technine price: 19900 price_max: 22900 price_min: 19900 tags: - season2006 - beginner - intermediate - freestyle - purple options: - Length price_varies: true available: true compare_at_price: compare_at_price_max: 0 compare_at_price_min: 0 compare_at_price_varies: false url: /products/comic-pastel featured_image: products/technine1.jpg images: - products/technine1.jpg - products/technine2.jpg - products/technine_detail.jpg description: 2005 Technine Comic Series Description The Comic series was developed to be the ultimate progressive freestyle board in the Technine line. Dependable edge control and a perfect flex pattern for jumping in the park or out of bounds. Landins and progression will come easy with this board and it will help your riding progress to the next level. Street rails, park jibs, backcountry booters and park jumps, this board will do it all. variants: - *product-3-var-1 - *product-3-var-2 - *product-3-var-3 - &product-4 id: 4 title: Comic ~ Orange handle: comic-orange type: Snowboards vendor: Technine price: 19900 price_max: 19900 price_min: 19900 price_varies: false available: true tags: - season2006 - beginner - intermediate - freestyle - orange options: - Length compare_at_price: 32900 compare_at_price_max: 32900 compare_at_price_min: 32900 compare_at_price_varies: false url: /products/comic-orange featured_image: products/technine3.jpg images: - products/technine3.jpg - products/technine4.jpg description: 2005 Technine Comic Series Description The Comic series was developed to be the ultimate progressive freestyle board in the Technine line. Dependable edge control and a perfect flex pattern for jumping in the park or out of bounds. Landins and progression will come easy with this board and it will help your riding progress to the next level. Street rails, park jibs, backcountry booters and park jumps, this board will do it all. variants: - *product-4-var-1 - &product-5 id: 5 title: Burton Boots handle: burton-boots type: Boots vendor: Burton price: 11900 price_max: 11900 price_min: 11900 price_varies: false available: true tags: - season2006 - beginner - intermediate - boots options: - Color - Shoe Size compare_at_price: 22900 compare_at_price_max: 22900 compare_at_price_min: 22900 compare_at_price_varies: false url: /products/burton-boots featured_image: products/burton.jpg images: - products/burton.jpg description: The Burton boots are particularly well on snowboards. The very best thing about them is that the according picture is cubic. This makes testing in a Vision testing environment very easy. variants: - *product-5-var-1 - *product-5-var-2 - *product-5-var-3 - *product-5-var-4 - *product-5-var-5 - *product-5-var-6 - &product-6 id: 6 title: Superbike 1198 S handle: superbike type: Superbike vendor: Ducati price: 2179500 price_max: 2179500 price_min: 2179500 price_varies: false available: true tags: - ducati - superbike - bike - street - racing - performance options: - Color compare_at_price: compare_at_price_max: 0 compare_at_price_min: 0 compare_at_price_varies: false url: /products/superbike featured_image: products/ducati.jpg images: - products/ducati.jpg description:

‘S’ PERFORMANCE

Producing 170hp (125kW) and with a dry weight of just 169kg (372.6lb), the new 1198 S now incorporates more World Superbike technology than ever before by taking the 1198 motor and adding top-of-the-range suspension, lightweight chassis components and a true racing-style traction control system designed for road use.

The high performance, fully adjustable 43mm Öhlins forks, which sport low friction titanium nitride-treated fork sliders, respond effortlessly to every imperfection in the tarmac. Beyond their advanced engineering solutions, one of the most important characteristics of Öhlins forks is their ability to communicate the condition and quality of the tyre-to-road contact patch, a feature that puts every rider in superior control. The suspension set-up at the rear is complemented with a fully adjustable Öhlins rear shock equipped with a ride enhancing top-out spring and mounted to a single-sided swingarm for outstanding drive and traction. The front-to-rear Öhlins package is completed with a control-enhancing adjustable steering damper.

variants: - *product-6-var-1 - &product-7 id: 7 title: Shopify Shirt handle: shopify-shirt type: Shirt vendor: Shopify price: 1900 price_max: 1900 price_min: 1900 price_varies: false available: true tags: - shopify - shirt - apparel - tshirt - clothing options: - Color - Size compare_at_price: compare_at_price_max: 0 compare_at_price_min: 0 compare_at_price_varies: false url: /products/shopify-shirt featured_image: products/shopify_shirt.png images: - products/shopify_shirt.png description:

High Quality Shopify Shirt. Wear your e-commerce solution with pride and attract attention anywhere you go.

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

variants: - *product-7-var-1 - *product-7-var-2 - *product-7-var-3 - *product-7-var-4 - &product-8 id: 8 title: Hooded Sweater handle: hooded-sweater type: Sweater vendor: Stormtech price: 5900 price_max: 5900 price_min: 5900 price_varies: false available: true tags: - sweater - hooded - apparel - clothing options: - Color - Size compare_at_price: 6900 compare_at_price_max: 6900 compare_at_price_min: 6900 compare_at_price_varies: false url: /products/hooded-sweater featured_image: products/hooded-sweater.jpg images: - products/hooded-sweater.jpg - products/hooded-sweater-b.jpg description:

Extra comfortable zip up sweater. Durable quality, ideal for any outdoor activities.

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

variants: - *product-8-var-1 - *product-8-var-2 - *product-8-var-3 - *product-8-var-4 - *product-8-var-5 - *product-8-var-6 - &product-9 id: 9 title: D3 Digital SLR Camera handle: d3 type: SLR vendor: Nikon price: 499995 price_max: 552500 price_min: 499995 price_varies: true available: true tags: - camera - slr - nikon - professional options: - Bundle compare_at_price: compare_at_price_max: 0 compare_at_price_min: 0 compare_at_price_varies: false url: /products/d3 featured_image: products/d3.jpg images: - products/d3.jpg - products/d3_2.jpg - products/d3_3.jpg description:

Flagship pro D-SLR with a 12.1-MP FX-format CMOS sensor, blazing 9 fps shooting at full FX resolution and low-noise performance up to 6400 ISO.

Nikon's original 12.1-megapixel FX-format (23.9 x 36mm) CMOS sensor: Couple Nikon's exclusive digital image processing system with the 12.1-megapixel FX-format and you'll get breathtakingly rich images while also reducing noise to unprecedented levels with even higher ISOs.

Continuous shooting at up to 9 frames per second: At full FX resolution and up to 11fps in the DX crop mode, the D3 offers uncompromised shooting speeds for fast-action and sports photography.

variants: - *product-9-var-1 - *product-9-var-2 - *product-9-var-3 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # Line Items # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- line_items: - &line_item-1 id: 1 title: 'Arbor Draft' subtitle: '151cm' price: 29900 line_price: 29900 quantity: 1 variant: *product-1-var-1 product: *product-1 - &line_item-2 id: 2 title: 'Comic ~ Orange' subtitle: '159cm' price: 19900 line_price: 39800 quantity: 2 variant: *product-4-var-1 product: *product-4 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # Link Lists # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- links: - &link-1 id: 1 title: Our Sale url: /collections/sale - &link-2 id: 2 title: Arbor Stuff url: /collections/arbor - &link-3 id: 3 title: All our Snowboards url: /collections/snowboards - &link-4 id: 4 title: Powered by Shopify url: 'http://shopify.com' - &link-5 id: 5 title: About Us url: /pages/about-us - &link-6 id: 6 title: Policies url: /pages/shipping - &link-7 id: 7 title: Contact Us url: /pages/contact - &link-8 id: 8 title: Our blog url: /blogs/bigcheese-blog - &link-9 id: 9 title: New Boots url: /products/burton-boots - &link-10 id: 10 title: Paginated Sale url: /collections/paginated-sale - &link-11 id: 11 title: Our Paginated blog url: /blogs/paginated-blog - &link-12 id: 12 title: Catalog url: /collections/all # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # Link Lists # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- link_lists: - &link-list-1 id: 1 title: 'Main Menu' handle: 'main-menu' links: - *link-12 - *link-5 - *link-7 - *link-8 - &link-list-2 id: 1 title: 'Footer Menu' handle: 'footer' links: - *link-5 - *link-6 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # Collections # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- collections: - &collection-1 id: 1 title: Frontpage handle: frontpage url: /collections/frontpage products: - *product-7 - *product-8 - *product-9 - &collection-2 id: 2 title: Arbor handle: arbor url: /collections/arbor products: - *product-1 - *product-2 - &collection-3 id: 3 title: Snowboards handle: snowboards url: /collections/snowboards description:

This is a description for my Snowboards collection.

products: - *product-1 - *product-2 - *product-3 - *product-4 - &collection-4 id: 4 title: Items On Sale handle: sale url: /collections/sale products: - *product-1 - &collection-5 id: 5 title: Paginated Sale handle: 'paginated-sale' url: '/collections/paginated-sale' products: - *product-1 - *product-2 - *product-3 - *product-4 products_count: 210 - &collection-6 id: 6 title: All products handle: 'all' url: '/collections/all' products: - *product-7 - *product-8 - *product-9 - *product-6 - *product-1 - *product-2 - *product-3 - *product-4 - *product-5 # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- # Pages and Blogs # =-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- pages: - &page-2 id: 1 title: Contact Us handle: contact url: /pages/contact author: Tobi content: "

You can contact us via phone under (555) 567-2222.

Our retail store is located at Rue d'Avignon 32, Avignon (Provence).

Opening Hours:
Monday through Friday: 9am - 6pm
Saturday: 10am - 3pm
Sunday: closed

" created_at: 2005-04-04 12:00 - &page-3 id: 2 title: About Us handle: about-us url: /pages/about-us author: Tobi content: "

Our company was founded in 1894 and we are since operating out of Avignon from the beautiful Provence.

We offer the highest quality products and are proud to serve our customers to their heart's content.

" created_at: 2005-04-04 12:00 - &page-4 id: 3 title: Shopping Cart handle: shopping-cart url: /pages/shopping-cart author: Tobi content: "
  • Your order is safe with us. Our checkout uses industry standard security to protect your information.
  • Your order will be billed immediately upon checkout.
  • ALL SALES ARE FINAL: Defective or damaged product will be exchanged
  • All orders are processed expediently: usually in under 24 hours.
" created_at: 2005-04-04 12:00 - &page-5 id: 4 title: Shipping and Handling handle: shipping url: /pages/shipping author: Tobi content:

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

created_at: 2005-04-04 12:00 - &page-6 id: 5 title: Frontpage handle: frontpage url: /pages/frontpage author: Tobi content:

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

created_at: 2005-04-04 12:00 blogs: - id: 1 handle: news title: News url: /blogs/news articles: - id: 3 title: 'Welcome to the new Foo Shop' author: Daniel content:

Welcome to your Shopify store! The jaded Pixel crew is really glad you decided to take Shopify for a spin.

To help you get you started with Shopify, here are a couple of tips regarding what you see on this page.

The text you see here is an article. To edit this article, create new articles or create new pages you can go to the Blogs & Pages tab of the administration menu.

The Shopify t-shirt above is a product and selling products is what Shopify is all about. To edit this product, or create new products you can go to the Products Tab in of the administration menu.

While you're looking around be sure to check out the Collections and Navigations tabs and soon you will be well on your way to populating your site.

And of course don't forget to browse the theme gallery to pick a new look for your shop!

Shopify is in beta
If you would like to make comments or suggestions please visit us in the Shopify Forums or drop us an email.

created_at: 2005-04-04 16:00 - id: 4 title: 'Breaking News: Restock on all sales products' author: Tobi content: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. created_at: 2005-04-04 12:00 articles_count: 2 - id: 2 handle: bigcheese-blog title: Bigcheese blog url: /blogs/bigcheese-blog articles: - id: 1 title: 'One thing you probably did not know yet...' author: Justin content: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. created_at: 2005-04-04 16:00 comments: - id: 1 author: John Smith email: john@smith.com content: Wow...great article man. status: published created_at: 2009-01-01 12:00 updated_at: 2009-02-01 12:00 url: "" - id: 2 author: John Jones email: john@jones.com content: I really enjoyed this article. And I love your shop! It's awesome. Shopify rocks! status: published created_at: 2009-03-01 12:00 updated_at: 2009-02-01 12:00 url: "http://somesite.com/" - id: 2 title: Fascinating author: Tobi content: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. created_at: 2005-04-06 12:00 comments: articles_count: 2 comments_enabled?: true comment_post_url: "" comments_count: 2 moderated?: true - id: 3 handle: paginated-blog title: Paginated blog url: /blogs/paginated-blog articles: - id: 6 title: 'One thing you probably did not know yet...' author: Justin content: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. created_at: 2005-04-04 16:00 - id: 7 title: Fascinating author: Tobi content: Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. created_at: 2005-04-06 12:00 articles_count: 200 ================================================ FILE: performance/shopify/weight_filter.rb ================================================ # frozen_string_literal: true module WeightFilter def weight(grams) format("%.2f", grams / 1000) end def weight_with_unit(grams) "#{weight(grams)} kg" end end ================================================ FILE: performance/tests/dropify/article.liquid ================================================

{{ article.title }}

posted {{ article.created_at | date: "%Y %h" }} by

{{ article.content }}
{% if blog.comments_enabled? %}

Comments

    {% for comment in article.comments %}
  • {{ comment.author }} said on {{ comment.created_at | date: "%B %d, %Y" }}:
    {{ comment.content }}
  • {% endfor %}
{% form article %}

Leave a comment

{% if form.posted_successfully? %} {% if blog.moderated? %}
Successfully posted your comment.
It will have to be approved by the blog owner first before showing up.
{% else %}
Successfully posted your comment.
{% endif %} {% endif %} {% if form.errors %}
Not all the fields have been filled out correctly!
{% endif %}
{% if blog.moderated? %}

comments have to be approved before showing up

{% endif %} {% endform %}
{% endif %} ================================================ FILE: performance/tests/dropify/blog.liquid ================================================

{{page.title}}

{% paginate blog.articles by 20 %} {% for article in blog.articles %}

{{ article.title }}

Posted on {{ article.created_at | date: "%B %d, '%y" }} by {{ article.author }}.

{{ article.content | strip_html | truncate: 250 }}
{% if blog.comments_enabled? %}

{{ article.comments_count }} comments

{% endif %}
{% endfor %} {% endpaginate %}
================================================ FILE: performance/tests/dropify/cart.liquid ================================================
{% if cart.item_count == 0 %}

Your shopping cart is looking rather empty...

{% else %}

You have {{ cart.item_count }} {{ cart.item_count | pluralize: 'product', 'products' }} in here!

{% if additional_checkout_buttons %}

- or -

{{ content_for_additional_checkout_buttons }}
{% endif %}
{% endif %}
================================================ FILE: performance/tests/dropify/collection.liquid ================================================ {% paginate collection.products by 20 %}
    {% for product in collection.products %}
  • {{product.title}}

    {{ product.description | strip_html | truncatewords: 35 }}

    {{ product.price_min | money }}{% if product.price_varies %} - {{ product.price_max | money }}{% endif %}

  • {% endfor %}
{% endpaginate %} ================================================ FILE: performance/tests/dropify/index.liquid ================================================

Featured Items

{% for product in collections.frontpage.products limit:1 offset:0 %}
{{ product.title | escape }}

{{ product.title }}

{{ product.description | strip_html | truncatewords: 18 }}

{{ product.price_min | money }}

{% endfor %} {% for product in collections.frontpage.products offset:1 %}
{{ product.title | escape }}

{{ product.title }}

{{ product.price_min | money }}

{% endfor %}
{% assign article = pages.frontpage %} {% if article.content != "" %}

{{ article.title }}

{{ article.content }}
{% else %}
In Admin > Blogs & Pages, create a page with the handle frontpage and it will show up here.
{{ "Learn more about handles" | link_to: "http://wiki.shopify.com/Handle" }}
{% endif %}

{% for article in blogs.news.articles offset:1 %}

{{ article.title }}

{{ article.content }}
{% endfor %}
================================================ FILE: performance/tests/dropify/page.liquid ================================================

{{page.title}}

{{page.content}}
================================================ FILE: performance/tests/dropify/product.liquid ================================================
{% for image in product.images %} {% if forloop.first %} {{product.title | escape }} {% else %} {{product.title | escape }} {% endif %} {% endfor %}

{{ product.title }}

  • Vendor: {{ product.vendor | link_to_vendor }}
  • Type: {{ product.type | link_to_type }}
{{ product.price_min | money }}{% if product.price_varies %} - {{ product.price_max | money }}{% endif %}
{{ product.description }}
================================================ FILE: performance/tests/dropify/theme.liquid ================================================ {{shop.name}} - {{page_title}} {{ 'textile.css' | global_asset_url | stylesheet_tag }} {{ 'lightbox/v204/lightbox.css' | global_asset_url | stylesheet_tag }} {{ 'prototype/1.6/prototype.js' | global_asset_url | script_tag }} {{ 'scriptaculous/1.8.2/scriptaculous.js' | global_asset_url | script_tag }} {{ 'lightbox/v204/lightbox.js' | global_asset_url | script_tag }} {{ 'option_selection.js' | shopify_asset_url | script_tag }} {{ 'layout.css' | asset_url | stylesheet_tag }} {{ 'shop.js' | asset_url | script_tag }} {{ content_for_header }}

Skip to navigation.

{% if cart.item_count > 0 %} {% endif %}

{{ content_for_layout }}

{% if tags %} {% endif %}


================================================ FILE: performance/tests/ripen/article.liquid ================================================

{{ article.title }}

posted {{ article.created_at | date: "%Y %h" }} by

{{ article.content }}
{% if blog.comments_enabled? %}

Comments

    {% for comment in article.comments %}
  • {{ comment.content }}
    Posted by {{ comment.author }} on {{ comment.created_at | date: "%B %d, %Y" }}
  • {% endfor %}
{% form article %}

Leave a comment

{% if form.posted_successfully? %} {% if blog.moderated? %}
Successfully posted your comment.
It will have to be approved by the blog owner first before showing up.
{% else %}
Successfully posted your comment.
{% endif %} {% endif %} {% if form.errors %}
Not all the fields have been filled out correctly!
{% endif %}
{% if blog.moderated? %}

comments have to be approved before showing up

{% endif %} {% endform %}
{% endif %} ================================================ FILE: performance/tests/ripen/blog.liquid ================================================

{{page.title}}

{% for article in blog.articles %}

{{ article.created_at | date: '%d %b' }} {{ article.title }}

{{ article.content }} {% if blog.comments_enabled? %}

{{ article.comments_count }} comments

{% endif %} {% endfor %}
================================================ FILE: performance/tests/ripen/cart.liquid ================================================
{% if cart.item_count == 0 %}

Your shopping cart is empty...

Continue shopping

{% else %}

{% for item in cart.items %} {% endfor %}
Product Qty Price Total Remove
{{ item.product.featured_image | product_img_url: 'thumb' | img_tag }} {{ item.title }} {{ item.price | money }} {{item.line_price | money }} Remove

Subtotal: {{cart.total_price | money_with_currency }}

{% if additional_checkout_buttons %}

- or -

{{ content_for_additional_checkout_buttons }}
{% endif %}
{% endif %}
================================================ FILE: performance/tests/ripen/collection.liquid ================================================
{% if collection.description %}
{{ collection.description }}
{% endif %} {% paginate collection.products by 20 %}
    {% for product in collection.products %}
  • {{ product.title | escape }}

    {{product.title}}

    {{ product.description | strip_html | truncatewords: 35 }}

    {{ product.price_min | money }}{% if product.price_varies %} - {{ product.price_max | money }}{% endif %}

  • {% endfor %}
{% endpaginate %}
================================================ FILE: performance/tests/ripen/index.liquid ================================================

Featured products...

{% assign article = pages.frontpage %} {% if article.content != "" %}

{{ article.title }}

{{ article.content }} {% else %} In Admin > Blogs & Pages, create a page with the handle frontpage and it will show up here.
{{ "Learn more about handles" | link_to: "http://wiki.shopify.com/Handle" }} {% endif %}
================================================ FILE: performance/tests/ripen/page.liquid ================================================

{{page.title}}

{{ page.content }}
================================================ FILE: performance/tests/ripen/product.liquid ================================================

{{ product.title }}

{% for image in product.images %} {% if forloop.first %} {{product.title | escape }} {% else %} {{product.title | escape }} {% endif %} {% endfor %}
  • Vendor: {{ product.vendor | link_to_vendor }}
  • Type: {{ product.type | link_to_type }}
{{ product.price_min | money }}{% if product.price_varies %} - {{ product.price_max | money }}{% endif %}
{% if product.available %}
{% else %} Sold Out! {% endif %}
{{ product.description }}
================================================ FILE: performance/tests/ripen/theme.liquid ================================================ {{shop.name}} - {{page_title}} {{ 'main.css' | asset_url | stylesheet_tag }} {{ 'shop.js' | asset_url | script_tag }} {{ 'mootools.js' | asset_url | script_tag }} {{ 'slimbox.js' | asset_url | script_tag }} {{ 'option_selection.js' | shopify_asset_url | script_tag }} {{ 'slimbox.css' | asset_url | stylesheet_tag }} {{ content_for_header }}

Skip to navigation.

{{ content_for_layout }}
{% if template != 'cart' %}
Shopping Cart
{% if cart.item_count != 0 %} {{ cart.item_count }} {{ cart.item_count | pluralize: 'item', 'items' }} in your cart {% else %} Your cart is empty {% endif %}
{% endif %}
================================================ FILE: performance/tests/tribble/404.liquid ================================================

Oh no!

Seems like you are looking for something that just isn't here. Try heading back to our main page. Or you can checkout some of our featured products below.

Featured Products

    {% for product in collections.frontpage.products %}
  • {{product.title}}

    {{ product.description | truncatewords: 15 }}

    {{ product.title | escape }}

    View Details {% if product.compare_at_price %} {% if product.price_min != product.compare_at_price %} {{product.compare_at_price | money}} - {% endif %} {% endif %} {{product.price_min | money}}

  • {% endfor %}
================================================ FILE: performance/tests/tribble/article.liquid ================================================

{{article.title}}

{{ article.created_at | date: "%b %d" }}
{{ article.content }}
{% if blog.comments_enabled? %}

Comments

    {% for comment in article.comments %}
  • {{ comment.content }}
    Posted by {{ comment.author }} on {{ comment.created_at | date: "%B %d, %Y" }}
  • {% endfor %}
{% form article %}

Leave a comment

{% if form.posted_successfully? %} {% if blog.moderated? %}
Successfully posted your comment.
It will have to be approved by the blog owner first before showing up.
{% else %}
Successfully posted your comment.
{% endif %} {% endif %} {% if form.errors %}
Not all the fields have been filled out correctly!
{% endif %}
{% if blog.moderated? %}

comments have to be approved before showing up

{% endif %} {% endform %}
{% endif %}

Why Shop With Us?

  • 24 Hours

    We're always here to help.

  • No Spam

    We'll never share your info.

  • Secure Servers

    Checkout is 256bit encrypted.

================================================ FILE: performance/tests/tribble/blog.liquid ================================================

Post from our blog...

{% paginate blog.articles by 20 %} {% for article in blog.articles %}

{{ article.title }}

{{ article.created_at | date: "%b %d" }}
{{ article.content }}
{% endfor %}
{{ paginate | default_pagination }}
{% endpaginate %}

Why Shop With Us?

  • 24 Hours

    We're always here to help.

  • No Spam

    We'll never share your info.

  • Secure Servers

    Checkout is 256bit encrypted.

================================================ FILE: performance/tests/tribble/cart.liquid ================================================
. {% if cart.item_count == 0 %}

Your cart is currently empty.

{% else %}

Your Cart ({{ cart.item_count }} {{ cart.item_count | pluralize: 'item', 'items' }}, {{cart.total_price | money_with_currency }} total)

{% for item in cart.items %} {% endfor %}
{{ item.product.featured_image | product_img_url: 'thumb' | img_tag }}

{{ item.title }}

{{item.line_price | money }} Remove
{{ pages.shopping-cart.content }}

Order Total: {{cart.total_price | money_with_currency }}

{% if additional_checkout_buttons %}

- or -

{{ content_for_additional_checkout_buttons }}
{% endif %}
{% endif %}

Other Products You Might Enjoy

    {% for product in collections.frontpage.products limit:2 %}
  • {{product.title}}

    {{ product.description | truncatewords: 15 }}

    {{ product.title | escape }}

    View Details {% if product.compare_at_price %} {% if product.price_min != product.compare_at_price %} {{product.compare_at_price | money}} - {% endif %} {% endif %} {{product.price_min | money}}

  • {% endfor %}

Why Shop With Us?

  • 24 Hours

    We're always here to help.

  • No Spam

    We'll never share your info.

  • Secure Servers

    Checkout is 256bit encrypted.

================================================ FILE: performance/tests/tribble/collection.liquid ================================================

{{ collection.title }}

{% if collection.description.size > 0 %}
{{ collection.description }}
{% endif %} {% paginate collection.products by 8 %}
    {% for product in collection.products %}
  • {{product.title}}

    {{ product.description | truncatewords: 15 }}

    {{ product.title | escape }}

    View Details {% if product.compare_at_price %} {% if product.price_min != product.compare_at_price %} {{product.compare_at_price | money}} - {% endif %} {% endif %} {{product.price_min | money}}

  • {% endfor %}
{{ paginate | default_pagination }}

Why Shop With Us?

  • 24 Hours

    We're always here to help.

  • No Spam

    We'll never share your info.

  • Secure Servers

    Checkout is 256bit encrypted.

{% endpaginate %} ================================================ FILE: performance/tests/tribble/index.liquid ================================================

Three Great Reasons You Should Shop With Us...

  • Free Shipping

    On all orders over $25

  • Top Quality

    Hand made in our shop

  • 100% Guarantee

    Any time, any reason

{{pages.alert.content}}
    {% for product in collections.frontpage.products %}
  • {{product.title}}

    {{ product.description | truncatewords: 15 }}

    {{ product.title | escape }}

    View Details {% if product.compare_at_price %} {% if product.price_min != product.compare_at_price %} {{product.compare_at_price | money}} - {% endif %} {% endif %} {{product.price_min | money}}

  • {% endfor %}

Why Shop With Us?

  • 24 Hours

    We're always here to help.

  • No Spam

    We'll never share your info.

  • Save Energy

    We're green, all the way.

  • Secure Servers

    Checkout is 256bits encrypted.

Our Company

{{pages.about-us.content | truncatewords: 49}} read more

================================================ FILE: performance/tests/tribble/page.liquid ================================================

{{page.title}}

{{page.content}}

Featured Products

    {% for product in collections.frontpage.products %}
  • {{product.title}}

    {{ product.description | truncatewords: 15 }}

    {{ product.title | escape }}

    View Details {% if product.compare_at_price %} {% if product.price_min != product.compare_at_price %} {{product.compare_at_price | money}} - {% endif %} {% endif %} {{product.price_min | money}}

  • {% endfor %}
================================================ FILE: performance/tests/tribble/product.liquid ================================================

{{ collection.title }} {{ product.title }}

Product Tags: {% for tag in product.tags %} {{ tag }} | {% endfor %}

{{ product.title }}

{{ product.description }}

{% if product.available %}

Product Options:

{% else %}

Sold out!

Sorry, we're all out of this product. Check back often and order when it returns

{% endif %}
{% for image in product.images %} {% if forloop.first %}
{{product.title | escape }}
{% else %} {% endif %} {% endfor %}
    {% for image in product.images %} {% if forloop.first %} {% else %}
  • {{product.title | escape }}
  • {% endif %} {% endfor %}

Why Shop With Us?

  • 24 Hours

    We're always here to help.

  • No Spam

    We'll never share your info.

  • Secure Servers

    Checkout is 256bit encrypted.

================================================ FILE: performance/tests/tribble/search.liquid ================================================

Search Results

{% if search.performed %} {% paginate search.results by 10 %} {% if search.results == empty %}
Your search for "{{search.terms | escape}}" did not yield any results
{% else %}
    {% for item in search.results %}
  • {{ item.title | link_to: item.url }}

    {{ item.content | strip_html | truncatewords: 65 | highlight: search.terms }} ... view this item

  • {% endfor %}
{% endif %}
{{ paginate | default_pagination }}

Why Shop With Us?

  • 24 Hours

    We're always here to help.

  • No Spam

    We'll never share your info.

  • Secure Servers

    Checkout is 256bit encrypted.

{% endpaginate %} {% endif %} ================================================ FILE: performance/tests/tribble/theme.liquid ================================================ {{shop.name}} - {{page_title}} {{ 'reset.css' | asset_url | stylesheet_tag }} {{ 'style.css' | asset_url | stylesheet_tag }} {{ 'lightbox.css' | asset_url | stylesheet_tag }} {{ 'prototype/1.6/prototype.js' | global_asset_url | script_tag }} {{ 'scriptaculous/1.8.2/scriptaculous.js' | global_asset_url | script_tag }} {{ 'lightbox.js' | asset_url | script_tag }} {{ 'option_selection.js' | shopify_asset_url | script_tag }} {{ content_for_header }}

Shopping Cart

{% if cart.item_count == 0 %} Your cart is currently empty {% else %} {{ cart.item_count }} {{ cart.item_count | pluralize: 'item', 'items' }} - Total: {{cart.total_price | money_with_currency }} - View Cart {% endif %}

{{shop.name}}

Tribble: A Shopify Theme

{{ content_for_layout }}
================================================ FILE: performance/tests/vogue/article.liquid ================================================

{{ article.title }}

posted {{ article.created_at | date: "%Y %h" }} by {{ article.author }}

{{ article.content }}
{% if blog.comments_enabled? %}

Comments

    {% for comment in article.comments %}
  • {{ comment.content }}
    Posted by {{ comment.author }} on {{ comment.created_at | date: "%B %d, %Y" }}
  • {% endfor %}
{% form article %}

Leave a comment

{% if form.posted_successfully? %} {% if blog.moderated? %}
Successfully posted your comment.
It will have to be approved by the blog owner first before showing up.
{% else %}
Successfully posted your comment.
{% endif %} {% endif %} {% if form.errors %}
Not all the fields have been filled out correctly!
{% endif %}
{% if blog.moderated? %}

comments have to be approved before showing up

{% endif %} {% endform %}
{% endif %} ================================================ FILE: performance/tests/vogue/blog.liquid ================================================

{{page.title}}

{% paginate blog.articles by 20 %} {% for article in blog.articles %}

{{ article.title }}

{% if blog.comments_enabled? %} {{ article.comments_count }} comments — {% endif %} posted {{ article.created_at | date: "%Y %h" }} by {{ article.author }}

{{ article.content }}
{% endfor %} {% endpaginate %}
================================================ FILE: performance/tests/vogue/cart.liquid ================================================

Shopping Cart

{% if cart.item_count == 0 %}

Your shopping basket is empty. Perhaps a featured item below is of interest...

{% tablerow product in collections.frontpage.products cols: 3 limit: 12 %} {% endtablerow %} {% else %}
{% for item in cart.items %} {% endfor %}
Item Description Price Qty Delete Total
{{ item.title | escape }}

{{ item.title }}

{{ item.product.description | strip_html | truncate: 120 }}
{{ item.price | money }}{% if item.variant.compare_at_price > item.price %}
{{ item.variant.compare_at_price | money }}{% endif %}
Remove {{ item.line_price | money }}

Subtotal {{ cart.total_price | money }}

{% if additional_checkout_buttons %}

- or -

{{ content_for_additional_checkout_buttons }}
{% endif %}
{% endif %} ================================================ FILE: performance/tests/vogue/collection.liquid ================================================ {% paginate collection.products by 12 %}{% if collection.products.size == 0 %} No products found in this collection.{% else %}

{{ collection.title }}

{{ collection.description }} {% tablerow product in collection.products cols: 3 %} {% endtablerow %} {% if paginate.pages > 1 %}
{{ paginate | default_pagination }}
{% endif %}{% endif %} {% endpaginate %} ================================================ FILE: performance/tests/vogue/index.liquid ================================================
{% assign article = pages.frontpage %} {% if article.content != "" %}

{{ article.title }}

{{ article.content }} {% else %} In Admin > Blogs & Pages, create a page with the handle frontpage and it will show up here.
{{ "Learn more about handles" | link_to: "http://wiki.shopify.com/Handle" }} {% endif %}
{% tablerow product in collections.frontpage.products cols: 3 limit: 12 %} {% endtablerow %} ================================================ FILE: performance/tests/vogue/page.liquid ================================================

{{ page.title }}

{{ page.content }} ================================================ FILE: performance/tests/vogue/product.liquid ================================================
{% for image in product.images %}{% if forloop.first %}
{{ product.title | escape }}
{% else %}
{{ product.title | escape }}
{% endif %}{% endfor %}

{{ product.title }}

{{ product.description }} {% if product.available %}
{% else %}

This product is temporarily unavailable

{% endif %}
Continue Shopping
Browse more {{ product.type | link_to_type }} or additional {{ product.vendor | link_to_vendor }} products.
================================================ FILE: performance/tests/vogue/theme.liquid ================================================ {{ shop.name }} — {{ page_title }} {{ 'stylesheet.css' | asset_url | stylesheet_tag }} {{ 'mootools.js' | global_asset_url | script_tag }} {{ 'slimbox.js' | global_asset_url | script_tag }} {{ 'option_selection.js' | shopify_asset_url | script_tag }} {{ content_for_header }}
{% if template == "search" %}

Search Results

{% endif %} {{ content_for_layout }}
{% if template != "cart" %}{% if template != "product" %}
{% if template == "index" %} {% if blogs.news.articles.size > 1 %} Subscribe

More news

{% endif %} {% endif %} {% if template == "collection" %}

Collection Tags

{% if collection.tags.size == 0 %} No tags found.{% else %} {% for tag in collection.tags %}{% if current_tags contains tag %} {{ tag | highlight_active_tag | link_to_remove_tag: tag }}{% else %} {{ tag | highlight_active_tag | link_to_add_tag: tag }}{% endif %}{% unless forloop.last %}, {% endunless %}{% endfor %}{% endif %}
{% endif %}

Navigation

{% if template != "page" %}

Featured Products

{% endif %}
{% endif %}{% endif %}
================================================ FILE: performance/theme_runner.rb ================================================ # frozen_string_literal: true # This profiler run simulates Shopify. # We are looking in the tests directory for liquid files and render them within the designated layout file. # We will also export a substantial database to liquid which the templates can render values of. # All this is to make the benchmark as non synthetic as possible. All templates and tests are lifted from # direct real-world usage and the profiler measures code that looks very similar to the way it looks in # Shopify which is likely the biggest user of liquid in the world which something to the tune of several # million Template#render calls a day. require_relative 'shopify/liquid' require_relative 'shopify/database' class ThemeRunner class FileSystem def initialize(path) @path = path end # Called by Liquid to retrieve a template file def read_template_file(template_path) File.read(@path + '/' + template_path + '.liquid') end end # Initialize a new liquid ThemeRunner instance # Will load all templates into memory, do this now so that we don't profile IO. def initialize @tests = Dir[__dir__ + '/tests/**/*.liquid'].collect do |test| next if File.basename(test) == 'theme.liquid' theme_path = File.dirname(test) + '/theme.liquid' { liquid: File.read(test), layout: (File.file?(theme_path) ? File.read(theme_path) : nil), template_name: test, } end.compact compile_all_tests end # `compile` will test just the compilation portion of liquid without any templates def compile @tests.each do |test_hash| Liquid::Template.new.parse(test_hash[:liquid]) Liquid::Template.new.parse(test_hash[:layout]) end end # `tokenize` will just test the tokenizen portion of liquid without any templates def tokenize ss = StringScanner.new("") @tests.each do |test_hash| tokenizer = Liquid::Tokenizer.new( source: test_hash[:liquid], string_scanner: ss, line_numbers: true, ) while tokenizer.shift; end end end # `run` is called to benchmark rendering and compiling at the same time def run each_test do |liquid, layout, assigns, page_template, template_name| compile_and_render(liquid, layout, assigns, page_template, template_name) end end # `render` is called to benchmark just the render portion of liquid def render @compiled_tests.each do |test| tmpl = test[:tmpl] assigns = test[:assigns] layout = test[:layout] if layout assigns['content_for_layout'] = tmpl.render!(assigns) layout.render!(assigns) else tmpl.render!(assigns) end end end private def render_layout(template, layout, assigns) assigns['content_for_layout'] = template.render!(assigns) layout&.render!(assigns) end def compile_and_render(template, layout, assigns, page_template, template_file) compiled_test = compile_test(template, layout, assigns, page_template, template_file) render_layout(compiled_test[:tmpl], compiled_test[:layout], compiled_test[:assigns]) end def compile_all_tests @compiled_tests = [] each_test do |liquid, layout, assigns, page_template, template_name| @compiled_tests << compile_test(liquid, layout, assigns, page_template, template_name) end @compiled_tests end def compile_test(template, layout, assigns, page_template, template_file) tmpl = init_template(page_template, template_file) parsed_template = tmpl.parse(template).dup if layout parsed_layout = tmpl.parse(layout) { tmpl: parsed_template, assigns: assigns, layout: parsed_layout } else { tmpl: parsed_template, assigns: assigns } end end # utility method with similar functionality needed in `compile_all_tests` and `run` def each_test # Dup assigns because will make some changes to them assigns = Database.tables.dup @tests.each do |test_hash| # Compute page_template outside of profiler run, uninteresting to profiler page_template = File.basename(test_hash[:template_name], File.extname(test_hash[:template_name])) yield(test_hash[:liquid], test_hash[:layout], assigns, page_template, test_hash[:template_name]) end end # set up a new Liquid::Template object for use in `compile_and_render` and `compile_test` def init_template(page_template, template_file) tmpl = Liquid::Template.new tmpl.assigns['page_title'] = 'Page title' tmpl.assigns['template'] = page_template tmpl.registers[:file_system] = ThemeRunner::FileSystem.new(File.dirname(template_file)) tmpl end end ================================================ FILE: performance/unit/expression_benchmark.rb ================================================ # frozen_string_literal: true require "benchmark/ips" # benchmark liquid lexing require 'liquid' RubyVM::YJIT.enable STRING_MARKUPS = [ "\"foo\"", "\"fooooooooooo\"", "\"foooooooooooooooooooooooooooooo\"", "'foo'", "'fooooooooooo'", "'foooooooooooooooooooooooooooooo'", ] VARIABLE_MARKUPS = [ "article", "article.title", "article.title.size", "very_long_variable_name_2024_11_05", "very_long_variable_name_2024_11_05.size", ] NUMBER_MARKUPS = [ "0", "35", "1241891024912849", "3.5", "3.51214128409128", "12381902839.123819283910283", "123.456.789", "-123", "-12.33", "-405.231", "-0", "0", "0.0", "0.0000000000000000000000", "0.00000000001", ] RANGE_MARKUPS = [ "(1..30)", "(1...30)", "(1..30..5)", "(1.0...30.0)", "(1.........30)", "(1..foo)", "(foo..30)", "(foo..bar)", "(foo...bar...100)", "(foo...bar...100.0)", ] LITERAL_MARKUPS = [ nil, 'nil', 'null', '', 'true', 'false', 'blank', 'empty', ] MARKUPS = { "string" => STRING_MARKUPS, "literal" => LITERAL_MARKUPS, "variable" => VARIABLE_MARKUPS, "number" => NUMBER_MARKUPS, "range" => RANGE_MARKUPS, } Benchmark.ips do |x| x.config(time: 5, warmup: 5) MARKUPS.each do |type, markups| x.report("Liquid::Expression#parse: #{type}") do markups.each do |markup| Liquid::Expression.parse(markup) end end end x.report("Liquid::Expression#parse: all") do MARKUPS.values.flatten.each do |markup| Liquid::Expression.parse(markup) end end end ================================================ FILE: performance/unit/lexer_benchmark.rb ================================================ # frozen_string_literal: true require "benchmark/ips" # benchmark liquid lexing require 'liquid' RubyVM::YJIT.enable EXPRESSIONS = [ "foo[1..2].baz", "12.0", "foo.bar.based", "21 - 62", "foo.bar.baz", "foo > 12", "foo < 12", "foo <= 12", "foo >= 12", "foo <> 12", "foo == 12", "foo != 12", "foo contains 12", "foo contains 'bar'", "foo != 'bar'", "'foo' contains 'bar'", '234089', "foo | default: -1", ] Benchmark.ips do |x| x.config(time: 10, warmup: 5) x.report("Liquid::Lexer#tokenize") do EXPRESSIONS.each do |expr| l = Liquid::Lexer.new(expr) l.tokenize end end x.compare! end ================================================ FILE: spec/ruby_liquid.rb ================================================ # frozen_string_literal: true # Liquid Spec Adapter for Shopify/liquid (Ruby reference implementation) # # Run with: bundle exec liquid-spec run spec/ruby_liquid.rb $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) require 'liquid' LiquidSpec.configure do |config| # Run core Liquid specs config.features = [:core] end # Compile a template string into a Liquid::Template LiquidSpec.compile do |ctx, source, options| ctx[:template] = Liquid::Template.parse(source, **options) end # Render a compiled template with the given context # @param ctx [Hash] adapter context containing :template # @param assigns [Hash] environment variables # @param options [Hash] :registers, :strict_errors, :exception_renderer LiquidSpec.render do |ctx, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( static_environments: assigns, registers: registers, rethrow_errors: options[:strict_errors], ) context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] ctx[:template].render(context) end ================================================ FILE: spec/ruby_liquid_lax.rb ================================================ # frozen_string_literal: true # Liquid Spec Adapter for Shopify/liquid with lax parsing mode # # Run with: bundle exec liquid-spec run spec/ruby_liquid_lax.rb $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) require 'liquid' LiquidSpec.configure do |config| config.features = [:core, :lax_parsing] end # Compile a template string into a Liquid::Template LiquidSpec.compile do |ctx, source, options| # Force lax mode options = options.merge(error_mode: :lax) ctx[:template] = Liquid::Template.parse(source, **options) end # Render a compiled template with the given context LiquidSpec.render do |ctx, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( static_environments: assigns, registers: registers, rethrow_errors: options[:strict_errors], ) context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] ctx[:template].render(context) end ================================================ FILE: spec/ruby_liquid_with_active_support.rb ================================================ # frozen_string_literal: true # Liquid Spec Adapter for Shopify/liquid with ActiveSupport loaded # # Run with: bundle exec liquid-spec run spec/ruby_liquid_with_active_support.rb $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) require 'active_support/all' require 'liquid' LiquidSpec.configure do |config| # Run core Liquid specs plus ActiveSupport SafeBuffer tests config.features = [:core, :activesupport] end # Compile a template string into a Liquid::Template LiquidSpec.compile do |ctx, source, options| ctx[:template] = Liquid::Template.parse(source, **options) end # Render a compiled template with the given context # @param ctx [Hash] adapter context containing :template # @param assigns [Hash] environment variables # @param options [Hash] :registers, :strict_errors, :exception_renderer LiquidSpec.render do |ctx, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( static_environments: assigns, registers: registers, rethrow_errors: options[:strict_errors], ) context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] ctx[:template].render(context) end ================================================ FILE: spec/ruby_liquid_yjit.rb ================================================ # frozen_string_literal: true # Liquid Spec Adapter for Shopify/liquid with YJIT + strict mode + ActiveSupport # # Run with: bundle exec liquid-spec run spec/ruby_liquid_yjit.rb $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) # Enable YJIT if available if defined?(RubyVM::YJIT) && RubyVM::YJIT.respond_to?(:enable) RubyVM::YJIT.enable end require 'active_support/all' require 'liquid' LiquidSpec.configure do |config| config.features = [:core, :activesupport] end # Compile a template string into a Liquid::Template LiquidSpec.compile do |ctx, source, options| # Force strict mode options = { error_mode: :strict }.merge(options) ctx[:template] = Liquid::Template.parse(source, **options) end # Render a compiled template with the given context LiquidSpec.render do |ctx, assigns, options| registers = Liquid::Registers.new(options[:registers] || {}) context = Liquid::Context.build( static_environments: assigns, registers: registers, rethrow_errors: options[:strict_errors], ) context.exception_renderer = options[:exception_renderer] if options[:exception_renderer] ctx[:template].render(context) end ================================================ FILE: test/fixtures/en_locale.yml ================================================ --- simple: "less is more" whatever: "something %{something}" errors: i18n: undefined_interpolation: "undefined key %{key}" unknown_translation: "translation '%{name}' wasn't found" syntax: oops: "something wasn't right" ================================================ FILE: test/integration/assign_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class AssignTest < Minitest::Test include Liquid def test_assign_with_hyphen_in_variable_name template_source = <<~END_TEMPLATE {% assign this-thing = 'Print this-thing' -%} {{ this-thing -}} END_TEMPLATE assert_template_result("Print this-thing", template_source) end def test_assigned_variable assert_template_result( '.foo.', '{% assign foo = values %}.{{ foo[0] }}.', { 'values' => %w(foo bar baz) }, ) assert_template_result( '.bar.', '{% assign foo = values %}.{{ foo[1] }}.', { 'values' => %w(foo bar baz) }, ) end def test_assign_with_filter assert_template_result( '.bar.', '{% assign foo = values | split: "," %}.{{ foo[1] }}.', { 'values' => "foo,bar,baz" }, ) end def test_assign_syntax_error assert_match_syntax_error(/assign/, '{% assign foo not values %}.') end def test_assign_uses_error_mode assert_match_syntax_error( "Expected dotdot but found pipe in ", "{% assign foo = ('X' | downcase) %}", error_mode: :strict, ) assert_template_result("", "{% assign foo = ('X' | downcase) %}", error_mode: :lax) end def test_expression_with_whitespace_in_square_brackets source = "{% assign r = a[ 'b' ] %}{{ r }}" assert_template_result('result', source, { 'a' => { 'b' => 'result' } }) end def test_assign_score_exceeding_resource_limit t = Template.parse("{% assign foo = 42 %}{% assign bar = 23 %}") t.resource_limits.assign_score_limit = 1 assert_equal("Liquid error: Memory limits exceeded", t.render) assert(t.resource_limits.reached?) t.resource_limits.assign_score_limit = 2 assert_equal("", t.render!) refute_nil(t.resource_limits.assign_score) end def test_assign_score_exceeding_limit_from_composite_object t = Template.parse("{% assign foo = 'aaaa' | reverse %}") t.resource_limits.assign_score_limit = 3 assert_equal("Liquid error: Memory limits exceeded", t.render) assert(t.resource_limits.reached?) t.resource_limits.assign_score_limit = 5 assert_equal("", t.render!) end def test_assign_score_of_int assert_equal(1, assign_score_of(123)) end def test_assign_score_of_string_counts_bytes assert_equal(3, assign_score_of('123')) assert_equal(5, assign_score_of('12345')) assert_equal(9, assign_score_of('すごい')) end def test_assign_score_of_array assert_equal(1, assign_score_of([])) assert_equal(2, assign_score_of([123])) assert_equal(6, assign_score_of([123, 'abcd'])) end def test_assign_score_of_hash assert_equal(1, assign_score_of({})) assert_equal(5, assign_score_of('int' => 123)) assert_equal(12, assign_score_of('int' => 123, 'str' => 'abcd')) end private class ObjectWrapperDrop < Liquid::Drop def initialize(obj) @obj = obj end def value @obj end end def assign_score_of(obj) context = Liquid::Context.new('drop' => ObjectWrapperDrop.new(obj)) Liquid::Template.parse('{% assign obj = drop.value %}').render!(context) context.resource_limits.assign_score end end ================================================ FILE: test/integration/blank_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class FoobarTag < Liquid::Tag def render_to_output_buffer(_context, output) output << ' ' output end end class BlankTest < Minitest::Test include Liquid N = 10 def wrap_in_for(body) "{% for i in (1..#{N}) %}#{body}{% endfor %}" end def wrap_in_if(body) "{% if true %}#{body}{% endif %}" end def wrap(body) wrap_in_for(body) + wrap_in_if(body) end def test_new_tags_are_not_blank_by_default with_custom_tag('foobar', FoobarTag) do assert_equal(" " * N, Liquid::Template.parse(wrap_in_for("{% foobar %}")).render!) end end def test_loops_are_blank assert_template_result("", wrap_in_for(" ")) end def test_if_else_are_blank assert_template_result("", "{% if true %} {% elsif false %} {% else %} {% endif %}") end def test_unless_is_blank assert_template_result("", wrap("{% unless true %} {% endunless %}")) end def test_mark_as_blank_only_during_parsing assert_template_result(" " * (N + 1), wrap(" {% if false %} this never happens, but still, this block is not blank {% endif %}")) end def test_comments_are_blank assert_template_result("", wrap(" {% comment %} whatever {% endcomment %} ")) end def test_captures_are_blank assert_template_result("", wrap(" {% capture foo %} whatever {% endcapture %} ")) end def test_nested_blocks_are_blank_but_only_if_all_children_are assert_template_result("", wrap(wrap(" "))) assert_template_result( "\n but this is not " * (N + 1), wrap('{% if true %} {% comment %} this is blank {% endcomment %} {% endif %} {% if true %} but this is not {% endif %}'), ) end def test_assigns_are_blank assert_template_result("", wrap(' {% assign foo = "bar" %} ')) end def test_whitespace_is_blank assert_template_result("", wrap(" ")) assert_template_result("", wrap("\t")) end def test_whitespace_is_not_blank_if_other_stuff_is_present body = " x " assert_template_result(body * (N + 1), wrap(body)) end def test_increment_is_not_blank assert_template_result(" 0" * 2 * (N + 1), wrap("{% assign foo = 0 %} {% increment foo %} {% decrement foo %}")) end def test_cycle_is_not_blank assert_template_result(" " * ((N + 1) / 2) + " ", wrap("{% cycle ' ', ' ' %}")) end def test_raw_is_not_blank assert_template_result(" " * (N + 1), wrap(" {% raw %} {% endraw %}")) end def test_include_is_blank assert_template_result( "foobar" * (N + 1), wrap("{% include 'foobar' %}"), partials: { 'foobar' => 'foobar' }, ) assert_template_result( " foobar " * (N + 1), wrap("{% include ' foobar ' %}"), partials: { ' foobar ' => ' foobar ' }, ) assert_template_result( " " * (N + 1), wrap(" {% include ' ' %} "), partials: { ' ' => ' ' }, ) end def test_case_is_blank assert_template_result("", wrap(" {% assign foo = 'bar' %} {% case foo %} {% when 'bar' %} {% when 'whatever' %} {% else %} {% endcase %} ")) assert_template_result("", wrap(" {% assign foo = 'else' %} {% case foo %} {% when 'bar' %} {% when 'whatever' %} {% else %} {% endcase %} ")) assert_template_result(" x " * (N + 1), wrap(" {% assign foo = 'else' %} {% case foo %} {% when 'bar' %} {% when 'whatever' %} {% else %} x {% endcase %} ")) end end ================================================ FILE: test/integration/block_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class BlockTest < Minitest::Test include Liquid def test_unexpected_end_tag source = '{% if true %}{% endunless %}' assert_match_syntax_error("Liquid syntax error (line 1): 'endunless' is not a valid delimiter for if tags. use endif", source) end def test_with_custom_tag with_custom_tag('testtag', Block) do assert(Liquid::Template.parse("{% testtag %} {% endtesttag %}")) end end def test_custom_block_tags_have_a_default_render_to_output_buffer_method_for_backwards_compatibility klass1 = Class.new(Block) do def render(*) 'hello' end end with_custom_tag('blabla', klass1) do template = Liquid::Template.parse("{% blabla %} bla {% endblabla %}") assert_equal('hello', template.render) buf = +'' output = template.render({}, output: buf) assert_equal('hello', output) assert_equal('hello', buf) assert_equal(buf.object_id, output.object_id) end klass2 = Class.new(klass1) do def render(*) 'foo' + super + 'bar' end end with_custom_tag('blabla', klass2) do template = Liquid::Template.parse("{% blabla %} foo {% endblabla %}") assert_equal('foohellobar', template.render) buf = +'' output = template.render({}, output: buf) assert_equal('foohellobar', output) assert_equal('foohellobar', buf) assert_equal(buf.object_id, output.object_id) end end end ================================================ FILE: test/integration/capture_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class CaptureTest < Minitest::Test include Liquid def test_captures_block_content_in_variable assert_template_result("test string", "{% capture 'var' %}test string{% endcapture %}{{var}}", {}) end def test_capture_with_hyphen_in_variable_name template_source = <<~END_TEMPLATE {% capture this-thing %}Print this-thing{% endcapture -%} {{ this-thing -}} END_TEMPLATE assert_template_result("Print this-thing", template_source) end def test_capture_to_variable_from_outer_scope_if_existing template_source = <<~END_TEMPLATE {% assign var = '' -%} {% if true -%} {% capture var %}first-block-string{% endcapture -%} {% endif -%} {% if true -%} {% capture var %}test-string{% endcapture -%} {% endif -%} {{var-}} END_TEMPLATE assert_template_result("test-string", template_source) end def test_assigning_from_capture template_source = <<~END_TEMPLATE {% assign first = '' -%} {% assign second = '' -%} {% for number in (1..3) -%} {% capture first %}{{number}}{% endcapture -%} {% assign second = first -%} {% endfor -%} {{ first }}-{{ second -}} END_TEMPLATE assert_template_result("3-3", template_source) end def test_increment_assign_score_by_bytes_not_characters t = Template.parse("{% capture foo %}すごい{% endcapture %}") t.render! assert_equal(9, t.resource_limits.assign_score) end end ================================================ FILE: test/integration/context_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class HundredCentes def to_liquid 100 end end class CentsDrop < Liquid::Drop def amount HundredCentes.new end def non_zero? true end end class ContextSensitiveDrop < Liquid::Drop def test @context['test'] end end class Category attr_accessor :name def initialize(name) @name = name end def to_liquid CategoryDrop.new(self) end end class ProductsDrop < Liquid::Drop def initialize(products) @products = products end def size @products.size end def to_liquid if @context["forloop"] @products.first(@context["forloop"].length) else @products end end end class CategoryDrop < Liquid::Drop attr_accessor :category, :context def initialize(category) @category = category end end class CounterDrop < Liquid::Drop def count @count ||= 0 @count += 1 end end class ArrayLike def fetch(index) end def [](index) @counts ||= [] @counts[index] ||= 0 @counts[index] += 1 end def to_liquid self end end class ContextTest < Minitest::Test include Liquid def setup @context = Liquid::Context.new end def test_variables @context['string'] = 'string' assert_equal('string', @context['string']) @context['num'] = 5 assert_equal(5, @context['num']) @context['time'] = Time.parse('2006-06-06 12:00:00') assert_equal(Time.parse('2006-06-06 12:00:00'), @context['time']) @context['date'] = Date.today assert_equal(Date.today, @context['date']) now = Time.now @context['datetime'] = now assert_equal(now, @context['datetime']) @context['bool'] = true assert_equal(true, @context['bool']) @context['bool'] = false assert_equal(false, @context['bool']) @context['nil'] = nil assert_nil(@context['nil']) assert_nil(@context['nil']) end def test_variables_not_existing assert_template_result("true", "{% if does_not_exist == nil %}true{% endif %}") end def test_scoping @context.push @context.pop assert_raises(Liquid::ContextError) do @context.pop end assert_raises(Liquid::ContextError) do @context.push @context.pop @context.pop end end def test_length_query assert_template_result( "true", "{% if numbers.size == 4 %}true{% endif %}", { "numbers" => [1, 2, 3, 4] }, ) assert_template_result( "true", "{% if numbers.size == 4 %}true{% endif %}", { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4 } }, ) assert_template_result( "true", "{% if numbers.size == 1000 %}true{% endif %}", { "numbers" => { 1 => 1, 2 => 2, 3 => 3, 4 => 4, 'size' => 1000 } }, ) end def test_hyphenated_variable assert_template_result("godz", "{{ oh-my }}", { "oh-my" => 'godz' }) end def test_add_filter filter = Module.new do def hi(output) output + ' hi!' end end context = Context.new context.add_filters(filter) assert_equal('hi? hi!', context.invoke(:hi, 'hi?')) context = Context.new assert_equal('hi?', context.invoke(:hi, 'hi?')) context.add_filters(filter) assert_equal('hi? hi!', context.invoke(:hi, 'hi?')) end def test_only_intended_filters_make_it_there filter = Module.new do def hi(output) output + ' hi!' end end context = Context.new assert_equal("Wookie", context.invoke("hi", "Wookie")) context.add_filters(filter) assert_equal("Wookie hi!", context.invoke("hi", "Wookie")) end def test_add_item_in_outer_scope @context['test'] = 'test' @context.push assert_equal('test', @context['test']) @context.pop assert_equal('test', @context['test']) end def test_add_item_in_inner_scope @context.push @context['test'] = 'test' assert_equal('test', @context['test']) @context.pop assert_nil(@context['test']) end def test_hierachical_data assigns = { 'hash' => { "name" => 'tobi' } } assert_template_result("tobi", "{{ hash.name }}", assigns) assert_template_result("tobi", '{{ hash["name"] }}', assigns) end def test_keywords assert_template_result("pass", "{% if true == expect %}pass{% endif %}", { "expect" => true }) assert_template_result("pass", "{% if false == expect %}pass{% endif %}", { "expect" => false }) end def test_digits assert_template_result("pass", "{% if 100 == expect %}pass{% endif %}", { "expect" => 100 }) assert_template_result("pass", "{% if 100.00 == expect %}pass{% endif %}", { "expect" => 100.00 }) end def test_strings assert_template_result("hello!", '{{ "hello!" }}') assert_template_result("hello!", "{{ 'hello!' }}") end def test_merge @context.merge("test" => "test") assert_equal('test', @context['test']) @context.merge("test" => "newvalue", "foo" => "bar") assert_equal('newvalue', @context['test']) assert_equal('bar', @context['foo']) end def test_array_notation assigns = { "test" => ["a", "b"] } assert_template_result("a", "{{ test[0] }}", assigns) assert_template_result("b", "{{ test[1] }}", assigns) assert_template_result("pass", "{% if test[2] == nil %}pass{% endif %}", assigns) end def test_recoursive_array_notation assigns = { "test" => { 'test' => [1, 2, 3, 4, 5] } } assert_template_result("1", "{{ test.test[0] }}", assigns) assigns = { "test" => [{ 'test' => 'worked' }] } assert_template_result("worked", "{{ test[0].test }}", assigns) end def test_hash_to_array_transition assigns = { 'colors' => { 'Blue' => ['003366', '336699', '6699CC', '99CCFF'], 'Green' => ['003300', '336633', '669966', '99CC99'], 'Yellow' => ['CC9900', 'FFCC00', 'FFFF99', 'FFFFCC'], 'Red' => ['660000', '993333', 'CC6666', 'FF9999'], }, } assert_template_result("003366", "{{ colors.Blue[0] }}", assigns) assert_template_result("FF9999", "{{ colors.Red[3] }}", assigns) end def test_try_first assigns = { 'test' => [1, 2, 3, 4, 5] } assert_template_result("1", "{{ test.first }}", assigns) assert_template_result("pass", "{% if test.last == 5 %}pass{% endif %}", assigns) assigns = { "test" => { "test" => [1, 2, 3, 4, 5] } } assert_template_result("1", "{{ test.test.first }}", assigns) assert_template_result("5", "{{ test.test.last }}", assigns) assigns = { "test" => [1] } assert_template_result("1", "{{ test.first }}", assigns) assert_template_result("1", "{{ test.last }}", assigns) end def test_access_hashes_with_hash_notation assigns = { 'products' => { 'count' => 5, 'tags' => ['deepsnow', 'freestyle'] } } assert_template_result("5", '{{ products["count"] }}', assigns) assert_template_result("deepsnow", '{{ products["tags"][0] }}', assigns) assert_template_result("deepsnow", '{{ products["tags"].first }}', assigns) assigns = { 'product' => { 'variants' => [{ 'title' => 'draft151cm' }, { 'title' => 'element151cm' }] } } assert_template_result("draft151cm", '{{ product["variants"][0]["title"] }}', assigns) assert_template_result("element151cm", '{{ product["variants"][1]["title"] }}', assigns) assert_template_result("draft151cm", '{{ product["variants"].first["title"] }}', assigns) assert_template_result("element151cm", '{{ product["variants"].last["title"] }}', assigns) end def test_access_variable_with_hash_notation assert_template_result('baz', '{{ ["foo"] }}', { "foo" => "baz" }) assert_template_result('baz', '{{ [bar] }}', { 'foo' => 'baz', 'bar' => 'foo' }) end def test_access_hashes_with_hash_access_variables assigns = { 'var' => 'tags', 'nested' => { 'var' => 'tags' }, 'products' => { 'count' => 5, 'tags' => ['deepsnow', 'freestyle'] }, } assert_template_result('deepsnow', '{{ products[var].first }}', assigns) assert_template_result('freestyle', '{{ products[nested.var].last }}', assigns) end def test_hash_notation_only_for_hash_access assigns = { "array" => [1, 2, 3, 4, 5] } assert_template_result("1", "{{ array.first }}", assigns) assert_template_result("pass", '{% if array["first"] == nil %}pass{% endif %}', assigns) assert_template_result("Hello", '{{ hash["first"] }}', { "hash" => { "first" => "Hello" } }) end def test_first_can_appear_in_middle_of_callchain assigns = { "product" => { 'variants' => [{ 'title' => 'draft151cm' }, { 'title' => 'element151cm' }] } } assert_template_result('draft151cm', '{{ product.variants[0].title }}', assigns) assert_template_result('element151cm', '{{ product.variants[1].title }}', assigns) assert_template_result('draft151cm', '{{ product.variants.first.title }}', assigns) assert_template_result('element151cm', '{{ product.variants.last.title }}', assigns) end def test_cents @context.merge("cents" => HundredCentes.new) assert_equal(100, @context['cents']) end def test_nested_cents @context.merge("cents" => { 'amount' => HundredCentes.new }) assert_equal(100, @context['cents.amount']) @context.merge("cents" => { 'cents' => { 'amount' => HundredCentes.new } }) assert_equal(100, @context['cents.cents.amount']) end def test_cents_through_drop @context.merge("cents" => CentsDrop.new) assert_equal(100, @context['cents.amount']) end def test_nested_cents_through_drop @context.merge("vars" => { "cents" => CentsDrop.new }) assert_equal(100, @context['vars.cents.amount']) end def test_drop_methods_with_question_marks @context.merge("cents" => CentsDrop.new) assert(@context['cents.non_zero?']) end def test_context_from_within_drop @context.merge("test" => '123', "vars" => ContextSensitiveDrop.new) assert_equal('123', @context['vars.test']) end def test_nested_context_from_within_drop @context.merge("test" => '123', "vars" => { "local" => ContextSensitiveDrop.new }) assert_equal('123', @context['vars.local.test']) end def test_ranges assert_template_result("1..5", '{{ (1..5) }}') assert_template_result("pass", '{% if (1..5) == expect %}pass{% endif %}', { "expect" => (1..5) }) assigns = { "test" => '5' } assert_template_result("1..5", "{{ (1..test) }}", assigns) assert_template_result("5..5", "{{ (test..test) }}", assigns) end def test_cents_through_drop_nestedly @context.merge("cents" => { "cents" => CentsDrop.new }) assert_equal(100, @context['cents.cents.amount']) @context.merge("cents" => { "cents" => { "cents" => CentsDrop.new } }) assert_equal(100, @context['cents.cents.cents.amount']) end def test_drop_with_variable_called_only_once @context['counter'] = CounterDrop.new assert_equal(1, @context['counter.count']) assert_equal(2, @context['counter.count']) assert_equal(3, @context['counter.count']) end def test_drop_with_key_called_only_once @context['counter'] = CounterDrop.new assert_equal(1, @context['counter["count"]']) assert_equal(2, @context['counter["count"]']) assert_equal(3, @context['counter["count"]']) end def test_proc_as_variable @context['dynamic'] = proc { 'Hello' } assert_equal('Hello', @context['dynamic']) end def test_lambda_as_variable @context['dynamic'] = proc { 'Hello' } assert_equal('Hello', @context['dynamic']) end def test_nested_lambda_as_variable @context['dynamic'] = { "lambda" => proc { 'Hello' } } assert_equal('Hello', @context['dynamic.lambda']) end def test_array_containing_lambda_as_variable @context['dynamic'] = [1, 2, proc { 'Hello' }, 4, 5] assert_equal('Hello', @context['dynamic[2]']) end def test_lambda_is_called_once @global = 0 @context['callcount'] = proc { @global += 1 @global.to_s } assert_equal('1', @context['callcount']) assert_equal('1', @context['callcount']) assert_equal('1', @context['callcount']) end def test_nested_lambda_is_called_once @global = 0 @context['callcount'] = { "lambda" => proc { @global += 1 @global.to_s }, } assert_equal('1', @context['callcount.lambda']) assert_equal('1', @context['callcount.lambda']) assert_equal('1', @context['callcount.lambda']) end def test_lambda_in_array_is_called_once @global = 0 p = proc { @global += 1 @global.to_s } @context['callcount'] = [1, 2, p, 4, 5] assert_equal('1', @context['callcount[2]']) assert_equal('1', @context['callcount[2]']) assert_equal('1', @context['callcount[2]']) end def test_access_to_context_from_proc @context.registers[:magic] = 345392 @context['magic'] = proc { @context.registers[:magic] } assert_equal(345392, @context['magic']) end def test_to_liquid_and_context_at_first_level @context['category'] = Category.new("foobar") assert_kind_of(CategoryDrop, @context['category']) assert_equal(@context, @context['category'].context) end def test_interrupt_avoids_object_allocations @context.interrupt? # ruby 3.0.0 allocates on the first call assert_no_object_allocations do @context.interrupt? end end def test_context_initialization_with_a_proc_in_environment contx = Context.new([test: ->(c) { c['poutine'] }], test: :foo) assert(contx) assert_nil(contx['poutine']) end def test_apply_global_filter global_filter_proc = ->(output) { "#{output} filtered" } context = Context.new context.global_filter = global_filter_proc assert_equal('hi filtered', context.apply_global_filter('hi')) end def test_static_environments_are_read_with_lower_priority_than_environments context = Context.build( static_environments: { 'shadowed' => 'static', 'unshadowed' => 'static' }, environments: { 'shadowed' => 'dynamic' }, ) assert_equal('dynamic', context['shadowed']) assert_equal('static', context['unshadowed']) end def test_apply_global_filter_when_no_global_filter_exist context = Context.new assert_equal('hi', context.apply_global_filter('hi')) end def test_new_isolated_subcontext_does_not_inherit_variables super_context = Context.new super_context['my_variable'] = 'some value' subcontext = super_context.new_isolated_subcontext assert_nil(subcontext['my_variable']) end def test_new_isolated_subcontext_inherits_static_environment super_context = Context.build(static_environments: { 'my_environment_value' => 'my value' }) subcontext = super_context.new_isolated_subcontext assert_equal('my value', subcontext['my_environment_value']) end def test_new_isolated_subcontext_inherits_resource_limits resource_limits = ResourceLimits.new({}) super_context = Context.new({}, {}, {}, false, resource_limits) subcontext = super_context.new_isolated_subcontext assert_equal(resource_limits, subcontext.resource_limits) end def test_new_isolated_subcontext_inherits_exception_renderer super_context = Context.new super_context.exception_renderer = ->(_e) { 'my exception message' } subcontext = super_context.new_isolated_subcontext assert_equal('my exception message', subcontext.handle_error(Liquid::Error.new)) end def test_new_isolated_subcontext_does_not_inherit_non_static_registers registers = { my_register: :my_value, } super_context = Context.new({}, {}, Registers.new(registers)) super_context.registers[:my_register] = :my_alt_value subcontext = super_context.new_isolated_subcontext assert_equal(:my_value, subcontext.registers[:my_register]) end def test_new_isolated_subcontext_inherits_static_registers super_context = Context.build(registers: { my_register: :my_value }) subcontext = super_context.new_isolated_subcontext assert_equal(:my_value, subcontext.registers[:my_register]) end def test_new_isolated_subcontext_registers_do_not_pollute_context super_context = Context.build(registers: { my_register: :my_value }) subcontext = super_context.new_isolated_subcontext subcontext.registers[:my_register] = :my_alt_value assert_equal(:my_value, super_context.registers[:my_register]) end def test_new_isolated_subcontext_inherits_filters my_filter = Module.new do def my_filter(*) 'my filter result' end end super_context = Context.new super_context.add_filters([my_filter]) subcontext = super_context.new_isolated_subcontext template = Template.parse('{{ 123 | my_filter }}') assert_equal('my filter result', template.render(subcontext)) end def test_disables_tag_specified context = Context.new context.with_disabled_tags(%w(foo bar)) do assert_equal(true, context.tag_disabled?("foo")) assert_equal(true, context.tag_disabled?("bar")) assert_equal(false, context.tag_disabled?("unknown")) end end def test_disables_nested_tags context = Context.new context.with_disabled_tags(["foo"]) do context.with_disabled_tags(["foo"]) do assert_equal(true, context.tag_disabled?("foo")) assert_equal(false, context.tag_disabled?("bar")) end context.with_disabled_tags(["bar"]) do assert_equal(true, context.tag_disabled?("foo")) assert_equal(true, context.tag_disabled?("bar")) context.with_disabled_tags(["foo"]) do assert_equal(true, context.tag_disabled?("foo")) assert_equal(true, context.tag_disabled?("bar")) end end assert_equal(true, context.tag_disabled?("foo")) assert_equal(false, context.tag_disabled?("bar")) end end def test_override_global_filter global = Module.new do def notice(output) "Global #{output}" end end local = Module.new do def notice(output) "Local #{output}" end end with_global_filter(global) do assert_equal('Global test', Template.parse("{{'test' | notice }}").render!) assert_equal('Local test', Template.parse("{{'test' | notice }}").render!({}, filters: [local])) end end def test_has_key_will_not_add_an_error_for_missing_keys with_error_modes(:strict) do context = Context.new context.key?('unknown') assert_empty(context.errors) end end def test_key_lookup_will_raise_for_missing_keys_when_strict_variables_is_enabled context = Context.new context.strict_variables = true assert_raises(Liquid::UndefinedVariable) do context['unknown'] end end def test_has_key_will_not_raise_for_missing_keys_when_strict_variables_is_enabled context = Context.new context.strict_variables = true refute(context.key?('unknown')) assert_empty(context.errors) end def test_context_always_uses_static_registers registers = { my_register: :my_value, } c = Context.new({}, {}, registers) assert_instance_of(Registers, c.registers) assert_equal(:my_value, c.registers[:my_register]) r = Registers.new(registers) c = Context.new({}, {}, r) assert_instance_of(Registers, c.registers) assert_equal(:my_value, c.registers[:my_register]) end def test_variable_to_liquid_returns_contextual_drop context = { "products" => ProductsDrop.new(["A", "B", "C", "D", "E"]), } template = Liquid::Template.parse(<<~LIQUID) {%- for i in (1..3) -%} for_loop_products_count: {{ products | size }} {% endfor %} unscoped_products_count: {{ products | size }} LIQUID result = template.render(context) assert_includes(result, "for_loop_products_count: 3") assert_includes(result, "unscoped_products_count: 5") end def test_new_isolated_context_inherits_parent_environment global_environment = Liquid::Environment.build(tags: {}) context = Context.build(environment: global_environment) subcontext = context.new_isolated_subcontext assert_equal(global_environment, subcontext.environment) end def test_newly_built_context_inherits_parent_environment global_environment = Liquid::Environment.build(tags: {}) context = Context.build(environment: global_environment) assert_equal(global_environment, context.environment) assert(context.environment.tags.each.to_a.empty?) end private def assert_no_object_allocations unless RUBY_ENGINE == 'ruby' skip("stackprof needed to count object allocations") end require 'stackprof' profile = StackProf.run(mode: :object) do yield end assert_equal(0, profile[:samples]) end end # ContextTest ================================================ FILE: test/integration/document_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class DocumentTest < Minitest::Test include Liquid def test_unexpected_outer_tag source = "{% else %}" assert_match_syntax_error("Liquid syntax error (line 1): Unexpected outer 'else' tag", source) end def test_unknown_tag source = "{% foo %}" assert_match_syntax_error("Liquid syntax error (line 1): Unknown tag 'foo'", source) end end ================================================ FILE: test/integration/drop_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ContextDrop < Liquid::Drop def scopes @context.scopes.size end def scopes_as_array (1..@context.scopes.size).to_a end def loop_pos @context['forloop.index'] end def liquid_method_missing(method) @context[method] end end class ProductDrop < Liquid::Drop class TextDrop < Liquid::Drop def array ['text1', 'text2'] end def text 'text1' end end class CatchallDrop < Liquid::Drop def liquid_method_missing(method) "catchall_method: #{method}" end end def texts TextDrop.new end def catchall CatchallDrop.new end def context ContextDrop.new end protected def callmenot "protected" end end class EnumerableDrop < Liquid::Drop def liquid_method_missing(method) method end def size 3 end def first 1 end def count 3 end def min 1 end def max 3 end def each yield 1 yield 2 yield 3 end end class RealEnumerableDrop < Liquid::Drop include Enumerable def liquid_method_missing(method) method end def each yield 1 yield 2 yield 3 end end class DropsTest < Minitest::Test include Liquid def test_product_drop tpl = Liquid::Template.parse(' ') assert_equal(' ', tpl.render!('product' => ProductDrop.new)) end def test_drop_does_only_respond_to_whitelisted_methods assert_equal("", Liquid::Template.parse("{{ product.inspect }}").render!('product' => ProductDrop.new)) assert_equal("", Liquid::Template.parse("{{ product.pretty_inspect }}").render!('product' => ProductDrop.new)) assert_equal("", Liquid::Template.parse("{{ product.whatever }}").render!('product' => ProductDrop.new)) assert_equal("", Liquid::Template.parse('{{ product | map: "inspect" }}').render!('product' => ProductDrop.new)) assert_equal("", Liquid::Template.parse('{{ product | map: "pretty_inspect" }}').render!('product' => ProductDrop.new)) assert_equal("", Liquid::Template.parse('{{ product | map: "whatever" }}').render!('product' => ProductDrop.new)) end def test_drops_respond_to_to_liquid assert_equal("text1", Liquid::Template.parse("{{ product.to_liquid.texts.text }}").render!('product' => ProductDrop.new)) assert_equal("text1", Liquid::Template.parse('{{ product | map: "to_liquid" | map: "texts" | map: "text" }}').render!('product' => ProductDrop.new)) end def test_text_drop output = Liquid::Template.parse(' {{ product.texts.text }} ').render!('product' => ProductDrop.new) assert_equal(' text1 ', output) end def test_catchall_unknown_method output = Liquid::Template.parse(' {{ product.catchall.unknown }} ').render!('product' => ProductDrop.new) assert_equal(' catchall_method: unknown ', output) end def test_catchall_integer_argument_drop output = Liquid::Template.parse(' {{ product.catchall[8] }} ').render!('product' => ProductDrop.new) assert_equal(' catchall_method: 8 ', output) end def test_text_array_drop output = Liquid::Template.parse('{% for text in product.texts.array %} {{text}} {% endfor %}').render!('product' => ProductDrop.new) assert_equal(' text1 text2 ', output) end def test_context_drop output = Liquid::Template.parse(' {{ context.bar }} ').render!('context' => ContextDrop.new, 'bar' => "carrot") assert_equal(' carrot ', output) end def test_context_drop_array_with_map output = Liquid::Template.parse(' {{ contexts | map: "bar" }} ').render!('contexts' => [ContextDrop.new, ContextDrop.new], 'bar' => "carrot") assert_equal(' carrotcarrot ', output) end def test_nested_context_drop output = Liquid::Template.parse(' {{ product.context.foo }} ').render!('product' => ProductDrop.new, 'foo' => "monkey") assert_equal(' monkey ', output) end def test_protected output = Liquid::Template.parse(' {{ product.callmenot }} ').render!('product' => ProductDrop.new) assert_equal(' ', output) end def test_object_methods_not_allowed [:dup, :clone, :singleton_class, :eval, :class_eval, :inspect].each do |method| output = Liquid::Template.parse(" {{ product.#{method} }} ").render!('product' => ProductDrop.new) assert_equal(' ', output) end end def test_scope assert_equal('1', Liquid::Template.parse('{{ context.scopes }}').render!('context' => ContextDrop.new)) assert_equal('2', Liquid::Template.parse('{%for i in dummy%}{{ context.scopes }}{%endfor%}').render!('context' => ContextDrop.new, 'dummy' => [1])) assert_equal('3', Liquid::Template.parse('{%for i in dummy%}{%for i in dummy%}{{ context.scopes }}{%endfor%}{%endfor%}').render!('context' => ContextDrop.new, 'dummy' => [1])) end def test_scope_though_proc assert_equal('1', Liquid::Template.parse('{{ s }}').render!('context' => ContextDrop.new, 's' => proc { |c| c['context.scopes'] })) assert_equal('2', Liquid::Template.parse('{%for i in dummy%}{{ s }}{%endfor%}').render!('context' => ContextDrop.new, 's' => proc { |c| c['context.scopes'] }, 'dummy' => [1])) assert_equal('3', Liquid::Template.parse('{%for i in dummy%}{%for i in dummy%}{{ s }}{%endfor%}{%endfor%}').render!('context' => ContextDrop.new, 's' => proc { |c| c['context.scopes'] }, 'dummy' => [1])) end def test_scope_with_assigns assert_equal('variable', Liquid::Template.parse('{% assign a = "variable"%}{{a}}').render!('context' => ContextDrop.new)) assert_equal('variable', Liquid::Template.parse('{% assign a = "variable"%}{%for i in dummy%}{{a}}{%endfor%}').render!('context' => ContextDrop.new, 'dummy' => [1])) assert_equal('test', Liquid::Template.parse('{% assign header_gif = "test"%}{{header_gif}}').render!('context' => ContextDrop.new)) assert_equal('test', Liquid::Template.parse("{% assign header_gif = 'test'%}{{header_gif}}").render!('context' => ContextDrop.new)) end def test_scope_from_tags assert_equal('1', Liquid::Template.parse('{% for i in context.scopes_as_array %}{{i}}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1])) assert_equal('12', Liquid::Template.parse('{%for a in dummy%}{% for i in context.scopes_as_array %}{{i}}{% endfor %}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1])) assert_equal('123', Liquid::Template.parse('{%for a in dummy%}{%for a in dummy%}{% for i in context.scopes_as_array %}{{i}}{% endfor %}{% endfor %}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1])) end def test_access_context_from_drop assert_equal('123', Liquid::Template.parse('{%for a in dummy%}{{ context.loop_pos }}{% endfor %}').render!('context' => ContextDrop.new, 'dummy' => [1, 2, 3])) end def test_enumerable_drop assert_equal('123', Liquid::Template.parse('{% for c in collection %}{{c}}{% endfor %}').render!('collection' => EnumerableDrop.new)) end def test_enumerable_drop_size assert_equal('3', Liquid::Template.parse('{{collection.size}}').render!('collection' => EnumerableDrop.new)) end def test_enumerable_drop_will_invoke_liquid_method_missing_for_clashing_method_names ["select", "each", "map", "cycle"].each do |method| assert_equal(method.to_s, Liquid::Template.parse("{{collection.#{method}}}").render!('collection' => EnumerableDrop.new)) assert_equal(method.to_s, Liquid::Template.parse("{{collection[\"#{method}\"]}}").render!('collection' => EnumerableDrop.new)) assert_equal(method.to_s, Liquid::Template.parse("{{collection.#{method}}}").render!('collection' => RealEnumerableDrop.new)) assert_equal(method.to_s, Liquid::Template.parse("{{collection[\"#{method}\"]}}").render!('collection' => RealEnumerableDrop.new)) end end def test_some_enumerable_methods_still_get_invoked [:count, :max].each do |method| assert_equal("3", Liquid::Template.parse("{{collection.#{method}}}").render!('collection' => RealEnumerableDrop.new)) assert_equal("3", Liquid::Template.parse("{{collection[\"#{method}\"]}}").render!('collection' => RealEnumerableDrop.new)) assert_equal("3", Liquid::Template.parse("{{collection.#{method}}}").render!('collection' => EnumerableDrop.new)) assert_equal("3", Liquid::Template.parse("{{collection[\"#{method}\"]}}").render!('collection' => EnumerableDrop.new)) end assert_equal("yes", Liquid::Template.parse("{% if collection contains 3 %}yes{% endif %}").render!('collection' => RealEnumerableDrop.new)) [:min, :first].each do |method| assert_equal("1", Liquid::Template.parse("{{collection.#{method}}}").render!('collection' => RealEnumerableDrop.new)) assert_equal("1", Liquid::Template.parse("{{collection[\"#{method}\"]}}").render!('collection' => RealEnumerableDrop.new)) assert_equal("1", Liquid::Template.parse("{{collection.#{method}}}").render!('collection' => EnumerableDrop.new)) assert_equal("1", Liquid::Template.parse("{{collection[\"#{method}\"]}}").render!('collection' => EnumerableDrop.new)) end end def test_empty_string_value_access assert_equal('', Liquid::Template.parse('{{ product[value] }}').render!('product' => ProductDrop.new, 'value' => '')) end def test_nil_value_access assert_equal('', Liquid::Template.parse('{{ product[value] }}').render!('product' => ProductDrop.new, 'value' => nil)) end def test_default_to_s_on_drops assert_equal('ProductDrop', Liquid::Template.parse("{{ product }}").render!('product' => ProductDrop.new)) assert_equal('EnumerableDrop', Liquid::Template.parse('{{ collection }}').render!('collection' => EnumerableDrop.new)) end def test_invokable_methods assert_equal(%w(to_liquid catchall context texts).to_set, ProductDrop.invokable_methods) assert_equal(%w(to_liquid scopes_as_array loop_pos scopes).to_set, ContextDrop.invokable_methods) assert_equal(%w(to_liquid size max min first count).to_set, EnumerableDrop.invokable_methods) assert_equal(%w(to_liquid max min sort count first).to_set, RealEnumerableDrop.invokable_methods) end end # DropsTest ================================================ FILE: test/integration/error_handling_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ErrorHandlingTest < Minitest::Test include Liquid def test_templates_parsed_with_line_numbers_renders_them_in_errors template = <<-LIQUID Hello, {{ errors.standard_error }} will raise a standard error. Bla bla test. {{ errors.syntax_error }} will raise a syntax error. This is an argument error: {{ errors.argument_error }} Bla. LIQUID expected = <<-TEXT Hello, Liquid error (line 3): standard error will raise a standard error. Bla bla test. Liquid syntax error (line 7): syntax error will raise a syntax error. This is an argument error: Liquid error (line 9): argument error Bla. TEXT output = Liquid::Template.parse(template, line_numbers: true).render('errors' => ErrorDrop.new) assert_equal(expected, output) end def test_standard_error template = Liquid::Template.parse(' {{ errors.standard_error }} ') assert_equal(' Liquid error: standard error ', template.render('errors' => ErrorDrop.new)) assert_equal(1, template.errors.size) assert_equal(StandardError, template.errors.first.class) end def test_syntax template = Liquid::Template.parse(' {{ errors.syntax_error }} ') assert_equal(' Liquid syntax error: syntax error ', template.render('errors' => ErrorDrop.new)) assert_equal(1, template.errors.size) assert_equal(SyntaxError, template.errors.first.class) end def test_argument template = Liquid::Template.parse(' {{ errors.argument_error }} ') assert_equal(' Liquid error: argument error ', template.render('errors' => ErrorDrop.new)) assert_equal(1, template.errors.size) assert_equal(ArgumentError, template.errors.first.class) end def test_missing_endtag_parse_time_error assert_match_syntax_error(/: 'for' tag was never closed\z/, ' {% for a in b %} ... ') end def test_unrecognized_operator with_error_modes(:strict) do assert_raises(SyntaxError) do Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ') end end end def test_lax_unrecognized_operator template = Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ', error_mode: :lax) assert_equal(' Liquid error: Unknown operator =! ', template.render) assert_equal(1, template.errors.size) assert_equal(Liquid::ArgumentError, template.errors.first.class) end def test_with_line_numbers_adds_numbers_to_parser_errors source = <<~LIQUID foobar {% "cat" | foobar %} bla LIQUID assert_match_syntax_error(/Liquid syntax error \(line 3\)/, source) end def test_with_line_numbers_adds_numbers_to_parser_errors_with_whitespace_trim source = <<~LIQUID foobar {%- "cat" | foobar -%} bla LIQUID assert_match_syntax_error(/Liquid syntax error \(line 3\)/, source) end def test_parsing_warn_with_line_numbers_adds_numbers_to_lexer_errors template = Liquid::Template.parse( ' foobar {% if 1 =! 2 %}ok{% endif %} bla ', error_mode: :warn, line_numbers: true, ) assert_equal( ['Liquid syntax error (line 4): Unexpected character = in "1 =! 2"'], template.warnings.map(&:message), ) end def test_parsing_strict_with_line_numbers_adds_numbers_to_lexer_errors err = assert_raises(SyntaxError) do Liquid::Template.parse( ' foobar {% if 1 =! 2 %}ok{% endif %} bla ', error_mode: :strict, line_numbers: true, ) end assert_equal('Liquid syntax error (line 4): Unexpected character = in "1 =! 2"', err.message) end def test_syntax_errors_in_nested_blocks_have_correct_line_number source = <<~LIQUID foobar {% if 1 != 2 %} {% foo %} {% endif %} bla LIQUID assert_match_syntax_error("Liquid syntax error (line 4): Unknown tag 'foo'", source) end def test_strict_error_messages err = assert_raises(SyntaxError) do Liquid::Template.parse(' {% if 1 =! 2 %}ok{% endif %} ', error_mode: :strict) end assert_equal('Liquid syntax error: Unexpected character = in "1 =! 2"', err.message) err = assert_raises(SyntaxError) do Liquid::Template.parse('{{%%%}}', error_mode: :strict) end assert_equal('Liquid syntax error: Unexpected character % in "{{%%%}}"', err.message) end def test_warnings template = Liquid::Template.parse('{% if ~~~ %}{{%%%}}{% else %}{{ hello. }}{% endif %}', error_mode: :warn) assert_equal(3, template.warnings.size) assert_equal('Unexpected character ~ in "~~~"', template.warnings[0].to_s(false)) assert_equal('Unexpected character % in "{{%%%}}"', template.warnings[1].to_s(false)) assert_equal('Expected id but found end_of_string in "{{ hello. }}"', template.warnings[2].to_s(false)) assert_equal('', template.render) end def test_warning_line_numbers template = Liquid::Template.parse("{% if ~~~ %}\n{{%%%}}{% else %}\n{{ hello. }}{% endif %}", error_mode: :warn, line_numbers: true) assert_equal('Liquid syntax error (line 1): Unexpected character ~ in "~~~"', template.warnings[0].message) assert_equal('Liquid syntax error (line 2): Unexpected character % in "{{%%%}}"', template.warnings[1].message) assert_equal('Liquid syntax error (line 3): Expected id but found end_of_string in "{{ hello. }}"', template.warnings[2].message) assert_equal(3, template.warnings.size) assert_equal([1, 2, 3], template.warnings.map(&:line_number)) end # Liquid should not catch Exceptions that are not subclasses of StandardError, like Interrupt and NoMemoryError def test_exceptions_propagate assert_raises(Exception) do template = Liquid::Template.parse('{{ errors.exception }}') template.render('errors' => ErrorDrop.new) end end def test_default_exception_renderer_with_internal_error template = Liquid::Template.parse('This is a runtime error: {{ errors.runtime_error }}', line_numbers: true) output = template.render('errors' => ErrorDrop.new) assert_equal('This is a runtime error: Liquid error (line 1): internal', output) assert_equal([Liquid::InternalError], template.errors.map(&:class)) end def test_setting_default_exception_renderer exceptions = [] default_exception_renderer = ->(e) { exceptions << e '' } env = Liquid::Environment.build(exception_renderer: default_exception_renderer) template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: env) output = template.render('errors' => ErrorDrop.new) assert_equal('This is a runtime error: ', output) assert_equal([Liquid::ArgumentError], template.errors.map(&:class)) end def test_setting_exception_renderer_on_environment exceptions = [] exception_renderer = ->(e) do exceptions << e '' end environment = Liquid::Environment.build(exception_renderer: exception_renderer) template = Liquid::Template.parse('This is a runtime error: {{ errors.argument_error }}', environment: environment) output = template.render('errors' => ErrorDrop.new) assert_equal('This is a runtime error: ', output) assert_equal([Liquid::ArgumentError], template.errors.map(&:class)) end def test_exception_renderer_exposing_non_liquid_error template = Liquid::Template.parse('This is a runtime error: {{ errors.runtime_error }}', line_numbers: true) exceptions = [] handler = ->(e) { exceptions << e e.cause } output = template.render({ 'errors' => ErrorDrop.new }, exception_renderer: handler) assert_equal('This is a runtime error: runtime error', output) assert_equal([Liquid::InternalError], exceptions.map(&:class)) assert_equal(exceptions, template.errors) assert_equal('#', exceptions.first.cause.inspect) end class TestFileSystem def read_template_file(_template_path) "{{ errors.argument_error }}" end end def test_included_template_name_with_line_numbers environment = Liquid::Environment.build(file_system: TestFileSystem.new) template = Liquid::Template.parse("Argument error:\n{% include 'product' %}", line_numbers: true, environment: environment) page = template.render('errors' => ErrorDrop.new) assert_equal("Argument error:\nLiquid error (product line 1): argument error", page) assert_equal("product", template.errors.first.template_name) end def test_bug_compatible_silencing_of_errors_in_blank_nodes output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}not blank{% assign x = 3 %}{% endif %}{{ x }}").render assert_equal("Liquid error: comparison of Integer with String failed0", output) output = Liquid::Template.parse("{% assign x = 0 %}{% if 1 < '2' %}{% assign x = 3 %}{% endif %}{{ x }}").render assert_equal("0", output) end def test_syntax_error_is_raised_with_template_name file_system = StubFileSystem.new("snippet" => "1\n2\n{{ 1") context = Liquid::Context.build( registers: { file_system: file_system }, ) template = Template.parse( '{% render "snippet" %}', line_numbers: true, ) template.name = "template/index" assert_equal( "Liquid syntax error (snippet line 3): Variable '{{' was not properly terminated with regexp: /\\}\\}/", template.render(context), ) end def test_syntax_error_is_raised_with_template_name_from_template_factory file_system = StubFileSystem.new("snippet" => "1\n2\n{{ 1") context = Liquid::Context.build( registers: { file_system: file_system, template_factory: StubTemplateFactory.new, }, ) template = Template.parse( '{% render "snippet" %}', line_numbers: true, ) template.name = "template/index" assert_equal( "Liquid syntax error (some/path/snippet line 3): Variable '{{' was not properly terminated with regexp: /\\}\\}/", template.render(context), ) end def test_error_is_raised_during_parse_with_template_name depth = Liquid::Block::MAX_DEPTH + 1 code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth template = Template.parse("{% render 'snippet' %}", line_numbers: true) context = Liquid::Context.build( registers: { file_system: StubFileSystem.new("snippet" => code), template_factory: StubTemplateFactory.new, }, ) assert_equal("Liquid error (some/path/snippet line 1): Nesting too deep", template.render(context)) end def test_internal_error_is_raised_with_template_name template = Template.new template.parse( "{% render 'snippet' %}", line_numbers: true, ) template.name = "template/index" context = Liquid::Context.build( registers: { file_system: StubFileSystem.new({}), }, ) assert_equal( "Liquid error (template/index line 1): internal", template.render(context), ) end end ================================================ FILE: test/integration/expression_test.rb ================================================ # frozen_string_literal: true require 'test_helper' require 'lru_redux' class ExpressionTest < Minitest::Test def test_keyword_literals assert_template_result("true", "{{ true }}") assert_expression_result(true, "true") end def test_string assert_template_result("single quoted", "{{'single quoted'}}") assert_template_result("double quoted", '{{"double quoted"}}') assert_template_result("spaced", "{{ 'spaced' }}") assert_template_result("spaced2", "{{ 'spaced2' }}") assert_template_result("emoji🔥", "{{ 'emoji🔥' }}") end def test_int assert_template_result("456", "{{ 456 }}") assert_expression_result(123, "123") assert_expression_result(12, "012") end def test_float assert_template_result("-17.42", "{{ -17.42 }}") assert_template_result("2.5", "{{ 2.5 }}") with_error_modes(:lax) do assert_expression_result(0.0, "0.....5") assert_expression_result(0.0, "-0..1") end assert_expression_result(1.5, "1.5") # this is a unfortunate quirky behavior of Liquid result = Expression.parse(".5") assert_kind_of(Liquid::VariableLookup, result) result = Expression.parse("-.5") assert_kind_of(Liquid::VariableLookup, result) end def test_range assert_template_result("3..4", "{{ ( 3 .. 4 ) }}") assert_expression_result(1..2, "(1..2)") assert_match_syntax_error( "Liquid syntax error (line 1): Invalid expression type 'false' in range expression", "{{ (false..true) }}", ) assert_match_syntax_error( "Liquid syntax error (line 1): Invalid expression type '(1..2)' in range expression", "{{ ((1..2)..3) }}", ) end def test_quirky_negative_sign_expression_markup result = Expression.parse("-", nil) assert(result.is_a?(VariableLookup)) assert_equal("-", result.name) # for this template, the expression markup is "-" assert_template_result( "", "{{ - 'theme.css' - }}", error_mode: :lax, ) end def test_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled cache = {} template = <<~LIQUID {% assign x = 1 %} {{ x }} {% assign x = 2 %} {{ x }} {% assign y = 1 %} {{ y }} LIQUID Liquid::Template.parse(template, expression_cache: cache).render assert_equal( ["1", "2", "x", "y"], cache.to_a.map { _1[0] }.sort, ) end def test_expression_cache_with_true_boolean skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled template = <<~LIQUID {% assign x = 1 %} {{ x }} {% assign x = 2 %} {{ x }} {% assign y = 1 %} {{ y }} LIQUID parse_context = ParseContext.new(expression_cache: true) Liquid::Template.parse(template, parse_context).render cache = parse_context.instance_variable_get(:@expression_cache) assert_equal( ["1", "2", "x", "y"], cache.to_a.map { _1[0] }.sort, ) end def test_expression_cache_with_lru_redux skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled cache = LruRedux::Cache.new(10) template = <<~LIQUID {% assign x = 1 %} {{ x }} {% assign x = 2 %} {{ x }} {% assign y = 1 %} {{ y }} LIQUID Liquid::Template.parse(template, expression_cache: cache).render assert_equal( ["1", "2", "x", "y"], cache.to_a.map { _1[0] }.sort, ) end def test_disable_expression_cache skip("Liquid-C does not support Expression caching") if defined?(Liquid::C) && Liquid::C.enabled template = <<~LIQUID {% assign x = 1 %} {{ x }} {% assign x = 2 %} {{ x }} {% assign y = 1 %} {{ y }} LIQUID parse_context = Liquid::ParseContext.new(expression_cache: false) Liquid::Template.parse(template, parse_context).render assert(parse_context.instance_variable_get(:@expression_cache).nil?) end def test_safe_parse_with_variable_lookup parse_context = Liquid::ParseContext.new parser = parse_context.new_parser('product.title') result = Liquid::Expression.safe_parse(parser) assert_instance_of(Liquid::VariableLookup, result) assert_equal('product', result.name) assert_equal(['title'], result.lookups) end def test_safe_parse_with_number parse_context = Liquid::ParseContext.new parser = parse_context.new_parser('42') result = Liquid::Expression.safe_parse(parser) assert_equal(42, result) end def test_safe_parse_raises_syntax_error_for_invalid_expression parse_context = Liquid::ParseContext.new parser = parse_context.new_parser('') error = assert_raises(Liquid::SyntaxError) do Liquid::Expression.safe_parse(parser) end assert_match(/is not a valid expression/, error.message) end private def assert_expression_result(expect, markup, **assigns) liquid = "{% if expect == #{markup} %}pass{% else %}got {{ #{markup} }}{% endif %}" assert_template_result("pass", liquid, { "expect" => expect, **assigns }) end end ================================================ FILE: test/integration/filter_kwarg_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class FilterKwargTest < Minitest::Test module KwargFilter def html_tag(_tag, attributes) attributes .map { |key, value| "#{key}='#{value}'" } .join(' ') end end include Liquid def test_can_parse_data_kwargs with_global_filter(KwargFilter) do assert_equal( "data-src='src' data-widths='100, 200'", Template.parse("{{ 'img' | html_tag: data-src: 'src', data-widths: '100, 200' }}").render(nil, nil), ) end end end ================================================ FILE: test/integration/filter_test.rb ================================================ # frozen_string_literal: true require 'test_helper' module MoneyFilter def money(input) format(' %d$ ', input) end def money_with_underscore(input) format(' %d$ ', input) end end module CanadianMoneyFilter def money(input) format(' %d$ CAD ', input) end end module SubstituteFilter def substitute(input, params = {}) input.gsub(/%\{(\w+)\}/) { |_match| params[Regexp.last_match(1)] } end end class FiltersTest < Minitest::Test include Liquid module OverrideObjectMethodFilter def tap(_input) "tap overridden" end end def setup @context = Context.new end def test_local_filter @context['var'] = 1000 @context.add_filters(MoneyFilter) assert_equal(' 1000$ ', Template.parse("{{var | money}}").render(@context)) end def test_underscore_in_filter_name @context['var'] = 1000 @context.add_filters(MoneyFilter) assert_equal(' 1000$ ', Template.parse("{{var | money_with_underscore}}").render(@context)) end def test_second_filter_overwrites_first @context['var'] = 1000 @context.add_filters(MoneyFilter) @context.add_filters(CanadianMoneyFilter) assert_equal(' 1000$ CAD ', Template.parse("{{var | money}}").render(@context)) end def test_size assert_template_result("4", "{{var | size}}", { "var" => 'abcd' }) end def test_join assert_template_result("1 2 3 4", "{{var | join}}", { "var" => [1, 2, 3, 4] }) end def test_sort assert_template_result("1 2 3 4", "{{numbers | sort | join}}", { "numbers" => [2, 1, 4, 3] }) assert_template_result( "alphabetic as expected", "{{words | sort | join}}", { "words" => ['expected', 'as', 'alphabetic'] }, ) assert_template_result("3", "{{value | sort}}", { "value" => 3 }) assert_template_result('are flower', "{{arrays | sort | join}}", { 'arrays' => ['flower', 'are'] }) assert_template_result( "Expected case sensitive", "{{case_sensitive | sort | join}}", { "case_sensitive" => ["sensitive", "Expected", "case"] }, ) end def test_sort_natural # Test strings assert_template_result( "Assert case Insensitive", "{{words | sort_natural | join}}", { "words" => ["case", "Assert", "Insensitive"] }, ) # Test hashes assert_template_result( "A b C", "{{hashes | sort_natural: 'a' | map: 'a' | join}}", { "hashes" => [{ "a" => "A" }, { "a" => "b" }, { "a" => "C" }] }, ) # Test objects @context['objects'] = [TestObject.new('A'), TestObject.new('b'), TestObject.new('C')] assert_equal('A b C', Template.parse("{{objects | sort_natural: 'a' | map: 'a' | join}}").render(@context)) end def test_compact # Test strings assert_template_result( "a b c", "{{words | compact | join}}", { "words" => ['a', nil, 'b', nil, 'c'] }, ) # Test hashes assert_template_result( "A C", "{{hashes | compact: 'a' | map: 'a' | join}}", { "hashes" => [{ "a" => "A" }, { "a" => nil }, { "a" => "C" }] }, ) # Test objects @context['objects'] = [TestObject.new('A'), TestObject.new(nil), TestObject.new('C')] assert_equal('A C', Template.parse("{{objects | compact: 'a' | map: 'a' | join}}").render(@context)) end def test_strip_html assert_template_result("bla blub", "{{ var | strip_html }}", { "var" => "bla blub" }) end def test_strip_html_ignore_comments_with_html assert_template_result( "bla blub", "{{ var | strip_html }}", { "var" => "bla blub" }, ) end def test_capitalize assert_template_result("Blub", "{{ var | capitalize }}", { "var" => "blub" }) end def test_nonexistent_filter_is_ignored assert_template_result("1000", "{{ var | xyzzy }}", { "var" => 1000 }) end def test_filter_with_keyword_arguments @context['surname'] = 'john' @context['input'] = 'hello %{first_name}, %{last_name}' @context.add_filters(SubstituteFilter) output = Template.parse(%({{ input | substitute: first_name: surname, last_name: 'doe' }})).render(@context) assert_equal('hello john, doe', output) end def test_override_object_method_in_filter assert_equal("tap overridden", Template.parse("{{var | tap}}").render!({ 'var' => 1000 }, filters: [OverrideObjectMethodFilter])) # tap still treated as a non-existent filter assert_equal("1000", Template.parse("{{var | tap}}").render!('var' => 1000)) end def test_liquid_argument_error source = "{{ '' | size: 'too many args' }}" exc = assert_raises(Liquid::ArgumentError) do Template.parse(source).render! end assert_match(/\ALiquid error: wrong number of arguments /, exc.message) assert_equal(exc.message, Template.parse(source).render) end end class FiltersInTemplate < Minitest::Test include Liquid def test_local_global with_global_filter(MoneyFilter) do assert_equal(" 1000$ ", Template.parse("{{1000 | money}}").render!(nil, nil)) assert_equal(" 1000$ CAD ", Template.parse("{{1000 | money}}").render!(nil, filters: CanadianMoneyFilter)) assert_equal(" 1000$ CAD ", Template.parse("{{1000 | money}}").render!(nil, filters: [CanadianMoneyFilter])) end end def test_local_filter_with_deprecated_syntax assert_equal(" 1000$ CAD ", Template.parse("{{1000 | money}}").render!(nil, CanadianMoneyFilter)) assert_equal(" 1000$ CAD ", Template.parse("{{1000 | money}}").render!(nil, [CanadianMoneyFilter])) end end # FiltersTest class TestObject < Liquid::Drop attr_accessor :a def initialize(a) @a = a end end ================================================ FILE: test/integration/hash_ordering_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class HashOrderingTest < Minitest::Test module MoneyFilter def money(input) format(' %d$ ', input) end end module CanadianMoneyFilter def money(input) format(' %d$ CAD ', input) end end include Liquid def test_global_register_order with_global_filter(MoneyFilter, CanadianMoneyFilter) do assert_equal(" 1000$ CAD ", Template.parse("{{1000 | money}}").render(nil, nil)) end end end ================================================ FILE: test/integration/hash_rendering_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class HashRenderingTest < Minitest::Test def test_render_empty_hash assert_template_result("{}", "{{ my_hash }}", { "my_hash" => {} }) end def test_render_hash_with_string_keys_and_values assert_template_result("{\"key1\"=>\"value1\", \"key2\"=>\"value2\"}", "{{ my_hash }}", { "my_hash" => { "key1" => "value1", "key2" => "value2" } }) end def test_render_hash_with_symbol_keys_and_integer_values assert_template_result("{:key1=>1, :key2=>2}", "{{ my_hash }}", { "my_hash" => { key1: 1, key2: 2 } }) end def test_render_nested_hash assert_template_result("{\"outer\"=>{\"inner\"=>\"value\"}}", "{{ my_hash }}", { "my_hash" => { "outer" => { "inner" => "value" } } }) end def test_render_hash_with_array_values assert_template_result("{\"numbers\"=>[1, 2, 3]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [1, 2, 3] } }) end def test_render_recursive_hash recursive_hash = { "self" => {} } recursive_hash["self"]["self"] = recursive_hash assert_template_result("{\"self\"=>{\"self\"=>{...}}}", "{{ my_hash }}", { "my_hash" => recursive_hash }) end def test_hash_with_downcase_filter assert_template_result("{\"key\"=>\"value\", \"anotherkey\"=>\"anothervalue\"}", "{{ my_hash | downcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_upcase_filter assert_template_result("{\"KEY\"=>\"VALUE\", \"ANOTHERKEY\"=>\"ANOTHERVALUE\"}", "{{ my_hash | upcase }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_strip_filter assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_escape_filter assert_template_result("{"Key"=>"Value", "AnotherKey"=>"AnotherValue"}", "{{ my_hash | escape }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_url_encode_filter assert_template_result("%7B%22Key%22%3D%3E%22Value%22%2C+%22AnotherKey%22%3D%3E%22AnotherValue%22%7D", "{{ my_hash | url_encode }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_strip_html_filter assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | strip_html }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_truncate__20_filter assert_template_result("{\"Key\"=>\"Value\", ...", "{{ my_hash | truncate: 20 }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_replace___key____replaced_key__filter assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | replace: 'key', 'replaced_key' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_append____appended_text__filter assert_template_result("{\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"} appended text", "{{ my_hash | append: ' appended text' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_hash_with_prepend___prepended_text___filter assert_template_result("prepended text {\"Key\"=>\"Value\", \"AnotherKey\"=>\"AnotherValue\"}", "{{ my_hash | prepend: 'prepended text ' }}", { "my_hash" => { "Key" => "Value", "AnotherKey" => "AnotherValue" } }) end def test_render_hash_with_array_values_empty assert_template_result("{\"numbers\"=>[]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [] } }) end def test_render_hash_with_array_values_hash assert_template_result("{\"numbers\"=>[{:foo=>42}]}", "{{ my_hash }}", { "my_hash" => { "numbers" => [{ foo: 42 }] } }) end def test_join_filter_with_hash array = [{ "key1" => "value1" }, { "key2" => "value2" }] glue = { "lol" => "wut" } assert_template_result("{\"key1\"=>\"value1\"}{\"lol\"=>\"wut\"}{\"key2\"=>\"value2\"}", "{{ my_array | join: glue }}", { "my_array" => array, "glue" => glue }) end def test_render_hash_with_hash_key assert_template_result("{{\"foo\"=>\"bar\"}=>42}", "{{ my_hash }}", { "my_hash" => { Hash["foo" => "bar"] => 42 } }) end def test_rendering_hash_with_custom_to_s_method_uses_custom_to_s assert_template_result("kewl", "{{ my_hash }}", { "my_hash" => HashWithCustomToS.new }) end def test_rendering_hash_without_custom_to_s_uses_default_inspect my_hash = HashWithoutCustomToS.new my_hash[:foo] = :bar assert_template_result("{:foo=>:bar}", "{{ my_hash }}", { "my_hash" => my_hash }) end end ================================================ FILE: test/integration/output_test.rb ================================================ # frozen_string_literal: true require 'test_helper' module FunnyFilter def make_funny(_input) 'LOL' end def cite_funny(input) "LOL: #{input}" end def add_smiley(input, smiley = ":-)") "#{input} #{smiley}" end def add_tag(input, tag = "p", id = "foo") %(<#{tag} id="#{id}">#{input}) end def paragraph(input) "

#{input}

" end def link_to(name, url) %(#{name}) end end class OutputTest < Minitest::Test include Liquid def setup @assigns = { 'car' => { 'bmw' => 'good', 'gm' => 'bad' }, } end def test_variable assert_template_result(" bmw ", " {{best_cars}} ", { "best_cars" => "bmw" }) end def test_variable_traversing_with_two_brackets source = "{{ site.data.menu[include.menu][include.locale] }}" assert_template_result("it works!", source, { "site" => { "data" => { "menu" => { "foo" => { "bar" => "it works!" } } } }, "include" => { "menu" => "foo", "locale" => "bar" }, }) end def test_variable_traversing source = " {{car.bmw}} {{car.gm}} {{car.bmw}} " assert_template_result(" good bad good ", source, @assigns) end def test_variable_piping text = %( {{ car.gm | make_funny }} ) expected = %( LOL ) assert_equal(expected, Template.parse(text).render!(@assigns, filters: [FunnyFilter])) end def test_variable_piping_with_input text = %( {{ car.gm | cite_funny }} ) expected = %( LOL: bad ) assert_equal(expected, Template.parse(text).render!(@assigns, filters: [FunnyFilter])) end def test_variable_piping_with_args text = %! {{ car.gm | add_smiley : ':-(' }} ! expected = %| bad :-( | assert_equal(expected, Template.parse(text).render!(@assigns, filters: [FunnyFilter])) end def test_variable_piping_with_no_args text = %( {{ car.gm | add_smiley }} ) expected = %| bad :-) | assert_equal(expected, Template.parse(text).render!(@assigns, filters: [FunnyFilter])) end def test_multiple_variable_piping_with_args text = %! {{ car.gm | add_smiley : ':-(' | add_smiley : ':-('}} ! expected = %| bad :-( :-( | assert_equal(expected, Template.parse(text).render!(@assigns, filters: [FunnyFilter])) end def test_variable_piping_with_multiple_args text = %( {{ car.gm | add_tag : 'span', 'bar'}} ) expected = %( bad ) assert_equal(expected, Template.parse(text).render!(@assigns, filters: [FunnyFilter])) end def test_variable_piping_with_variable_args text = %( {{ car.gm | add_tag : 'span', car.bmw}} ) expected = %( bad ) assert_equal(expected, Template.parse(text).render!(@assigns, filters: [FunnyFilter])) end def test_multiple_pipings assigns = { 'best_cars' => 'bmw' } text = %( {{ best_cars | cite_funny | paragraph }} ) expected = %(

LOL: bmw

) assert_equal(expected, Template.parse(text).render!(assigns, filters: [FunnyFilter])) end def test_link_to text = %( {{ 'Typo' | link_to: 'http://typo.leetsoft.com' }} ) expected = %( Typo ) assert_equal(expected, Template.parse(text).render!(@assigns, filters: [FunnyFilter])) end end # OutputTest ================================================ FILE: test/integration/parsing_quirks_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ParsingQuirksTest < Minitest::Test include Liquid def test_parsing_css text = " div { font-weight: bold; } " assert_equal(text, Template.parse(text).render!) end def test_raise_on_single_close_bracet assert_raises(SyntaxError) do Template.parse("text {{method} oh nos!") end end def test_raise_on_label_and_no_close_bracets assert_raises(SyntaxError) do Template.parse("TEST {{ ") end end def test_raise_on_label_and_no_close_bracets_percent assert_raises(SyntaxError) do Template.parse("TEST {% ") end end def test_error_on_empty_filter assert(Template.parse("{{test}}")) with_error_modes(:lax) do assert(Template.parse("{{|test}}")) end with_error_modes(:strict) do assert_raises(SyntaxError) { Template.parse("{{|test}}") } assert_raises(SyntaxError) { Template.parse("{{test |a|b|}}") } end end def test_meaningless_parens_error with_error_modes(:strict) do assert_raises(SyntaxError) do markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" Template.parse("{% if #{markup} %} YES {% endif %}") end end end def test_unexpected_characters_syntax_error with_error_modes(:strict) do assert_raises(SyntaxError) do markup = "true && false" Template.parse("{% if #{markup} %} YES {% endif %}") end assert_raises(SyntaxError) do markup = "false || true" Template.parse("{% if #{markup} %} YES {% endif %}") end end end def test_no_error_on_lax_empty_filter assert(Template.parse("{{test |a|b|}}", error_mode: :lax)) assert(Template.parse("{{test}}", error_mode: :lax)) assert(Template.parse("{{|test|}}", error_mode: :lax)) end def test_meaningless_parens_lax with_error_modes(:lax) do assigns = { 'b' => 'bar', 'c' => 'baz' } markup = "a == 'foo' or (b == 'bar' and c == 'baz') or false" assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}", assigns) end end def test_unexpected_characters_silently_eat_logic_lax with_error_modes(:lax) do markup = "true && false" assert_template_result(' YES ', "{% if #{markup} %} YES {% endif %}") markup = "false || true" assert_template_result('', "{% if #{markup} %} YES {% endif %}") end end def test_raise_on_invalid_tag_delimiter assert_raises(Liquid::SyntaxError) do Template.new.parse('{% end %}') end end def test_unanchored_filter_arguments with_error_modes(:lax) do assert_template_result('hi', "{{ 'hi there' | split$$$:' ' | first }}") assert_template_result('x', "{{ 'X' | downcase) }}") # After the messed up quotes a filter without parameters (reverse) should work # but one with parameters (remove) shouldn't be detected. assert_template_result('here', "{{ 'hi there' | split:\"t\"\" | reverse | first}}") assert_template_result('hi ', "{{ 'hi there' | split:\"t\"\" | remove:\"i\" | first}}") end end def test_invalid_variables_work with_error_modes(:lax) do assert_template_result('bar', "{% assign 123foo = 'bar' %}{{ 123foo }}") assert_template_result('123', "{% assign 123 = 'bar' %}{{ 123 }}") end end def test_extra_dots_in_ranges with_error_modes(:lax) do assert_template_result('12345', "{% for i in (1...5) %}{{ i }}{% endfor %}") end end def test_blank_variable_markup assert_template_result('', "{{}}") end def test_lookup_on_var_with_literal_name assigns = { "blank" => { "x" => "result" } } assert_template_result('result', "{{ blank.x }}", assigns) assert_template_result('result', "{{ blank['x'] }}", assigns) end def test_contains_in_id assert_template_result(' YES ', '{% if containsallshipments == true %} YES {% endif %}', { 'containsallshipments' => true }) end def test_incomplete_expression with_error_modes(:lax) do assert_template_result("false", "{{ false - }}") assert_template_result("false", "{{ false > }}") assert_template_result("false", "{{ false < }}") assert_template_result("false", "{{ false = }}") assert_template_result("false", "{{ false ! }}") assert_template_result("false", "{{ false 1 }}") assert_template_result("false", "{{ false a }}") assert_template_result("false", "{% liquid assign foo = false -\n%}{{ foo }}") assert_template_result("false", "{% liquid assign foo = false >\n%}{{ foo }}") assert_template_result("false", "{% liquid assign foo = false <\n%}{{ foo }}") assert_template_result("false", "{% liquid assign foo = false =\n%}{{ foo }}") assert_template_result("false", "{% liquid assign foo = false !\n%}{{ foo }}") assert_template_result("false", "{% liquid assign foo = false 1\n%}{{ foo }}") assert_template_result("false", "{% liquid assign foo = false a\n%}{{ foo }}") end end end # ParsingQuirksTest ================================================ FILE: test/integration/profiler_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ProfilerTest < Minitest::Test class TestDrop < Liquid::Drop def initialize(value) super() @value = value end def to_s artificial_execution_time @value end private # Monotonic clock precision fluctuate based on the operating system # By introducing a small sleep we ensure ourselves to register a non zero unit of time def artificial_execution_time sleep(Process.clock_getres(Process::CLOCK_MONOTONIC)) end end include Liquid class ProfilingFileSystem def read_template_file(template_path) "Rendering template {% assign template_name = '#{template_path}'%}\n{{ template_name }}" end end def setup Liquid::Environment.default.file_system = ProfilingFileSystem.new end def test_template_allows_flagging_profiling t = Template.parse("{{ 'a string' | upcase }}") t.render! assert_nil(t.profiler) end def test_parse_makes_available_simple_profiling t = Template.parse("{{ 'a string' | upcase }}", profile: true) t.render! assert_equal(1, t.profiler.length) node = t.profiler[0] assert_equal(" 'a string' | upcase ", node.code) end def test_render_ignores_raw_strings_when_profiling t = Template.parse("This is raw string\nstuff\nNewline", profile: true) t.render! assert_equal(0, t.profiler.length) end def test_profiling_includes_line_numbers_of_liquid_nodes t = Template.parse("{{ 'a string' | upcase }}\n{% increment test %}", profile: true) t.render! assert_equal(2, t.profiler.length) # {{ 'a string' | upcase }} assert_equal(1, t.profiler[0].line_number) # {{ increment test }} assert_equal(2, t.profiler[1].line_number) end def test_profiling_includes_line_numbers_of_included_partials t = Template.parse("{% include 'a_template' %}", profile: true) t.render! included_children = t.profiler[0].children # {% assign template_name = 'a_template' %} assert_equal(1, included_children[0].line_number) # {{ template_name }} assert_equal(2, included_children[1].line_number) end def test_profiling_render_tag t = Template.parse("{% render 'a_template' %}", profile: true) t.render! render_children = t.profiler[0].children render_children.each do |timing| assert_equal('a_template', timing.partial) end assert_equal([1, 2], render_children.map(&:line_number)) end def test_profiling_times_the_rendering_of_tokens t = Template.parse("{% include 'a_template' %}", profile: true) t.render! node = t.profiler[0] refute_nil(node.render_time) end def test_profiling_times_the_entire_render t = Template.parse("{% include 'a_template' %}", profile: true) t.render! assert(t.profiler.total_render_time >= 0, "Total render time was not calculated") end class SleepTag < Liquid::Tag def initialize(tag_name, markup, parse_context) super @duration = Float(markup) end def render_to_output_buffer(_context, _output) sleep(@duration) end end def test_profiling_multiple_renders with_custom_tag('sleep', SleepTag) do context = Liquid::Context.new t = Liquid::Template.parse("{% sleep 0.001 %}", profile: true) context.template_name = 'index' t.render!(context) context.template_name = 'layout' first_render_time = context.profiler.total_time t.render!(context) profiler = context.profiler children = profiler.children assert_operator(first_render_time, :>=, 0.001) assert_operator(profiler.total_time, :>=, 0.001 + first_render_time) assert_equal(["index", "layout"], children.map(&:template_name)) assert_equal([nil, nil], children.map(&:code)) assert_equal(profiler.total_time, children.map(&:total_time).reduce(&:+)) end end def test_profiling_uses_include_to_mark_children t = Template.parse("{{ 'a string' | upcase }}\n{% include 'a_template' %}", profile: true) t.render! include_node = t.profiler[1] assert_equal(2, include_node.children.length) end def test_profiling_marks_children_with_the_name_of_included_partial t = Template.parse("{{ 'a string' | upcase }}\n{% include 'a_template' %}", profile: true) t.render! include_node = t.profiler[1] include_node.children.each do |child| assert_equal("a_template", child.partial) end end def test_profiling_supports_multiple_templates t = Template.parse("{{ 'a string' | upcase }}\n{% include 'a_template' %}\n{% include 'b_template' %}", profile: true) t.render! a_template = t.profiler[1] a_template.children.each do |child| assert_equal("a_template", child.partial) end b_template = t.profiler[2] b_template.children.each do |child| assert_equal("b_template", child.partial) end end def test_profiling_supports_rendering_the_same_partial_multiple_times t = Template.parse("{{ 'a string' | upcase }}\n{% include 'a_template' %}\n{% include 'a_template' %}", profile: true) t.render! a_template1 = t.profiler[1] a_template1.children.each do |child| assert_equal("a_template", child.partial) end a_template2 = t.profiler[2] a_template2.children.each do |child| assert_equal("a_template", child.partial) end end def test_can_iterate_over_each_profiling_entry t = Template.parse("{{ 'a string' | upcase }}\n{% increment test %}", profile: true) t.render! timing_count = 0 t.profiler.each do |_timing| timing_count += 1 end assert_equal(2, timing_count) end def test_profiling_marks_children_of_if_blocks t = Template.parse("{% if true %} {% increment test %} {{ test }} {% endif %}", profile: true) t.render! assert_equal(1, t.profiler.length) assert_equal(2, t.profiler[0].children.length) end def test_profiling_marks_children_of_for_blocks t = Template.parse("{% for item in collection %} {{ item }} {% endfor %}", profile: true) t.render!("collection" => ["one", "two"]) assert_equal(1, t.profiler.length) # Will profile each invocation of the for block assert_equal(2, t.profiler[0].children.length) end def test_profiling_supports_self_time t = Template.parse("{% for item in collection %} {{ item }} {% endfor %}", profile: true) collection = [ TestDrop.new("one"), TestDrop.new("two"), ] output = t.render!("collection" => collection) assert_equal(" one two ", output) leaf = t.profiler[0].children[0] assert_operator(leaf.self_time, :>, 0.0) end def test_profiling_supports_total_time t = Template.parse("{% if true %} {{ test }} {% endif %}", profile: true) output = t.render!("test" => TestDrop.new("one")) assert_equal(" one ", output) assert_operator(t.profiler[0].total_time, :>, 0.0) end end ================================================ FILE: test/integration/security_test.rb ================================================ # frozen_string_literal: true require 'test_helper' module SecurityFilter def add_one(input) "#{input} + 1" end end class SecurityTest < Minitest::Test include Liquid def setup @assigns = {} end def test_no_instance_eval text = %( {{ '1+1' | instance_eval }} ) expected = %( 1+1 ) assert_equal(expected, Template.parse(text).render!(@assigns)) end def test_no_existing_instance_eval text = %( {{ '1+1' | __instance_eval__ }} ) expected = %( 1+1 ) assert_equal(expected, Template.parse(text).render!(@assigns)) end def test_no_instance_eval_after_mixing_in_new_filter text = %( {{ '1+1' | instance_eval }} ) expected = %( 1+1 ) assert_equal(expected, Template.parse(text).render!(@assigns)) end def test_no_instance_eval_later_in_chain text = %( {{ '1+1' | add_one | instance_eval }} ) expected = %( 1+1 + 1 ) assert_equal(expected, Template.parse(text).render!(@assigns, filters: SecurityFilter)) end def test_does_not_permanently_add_filters_to_symbol_table current_symbols = Symbol.all_symbols # MRI imprecisely marks objects found on the C stack, which can result # in uninitialized memory being marked. This can even result in the test failing # deterministically for a given compilation of ruby. Using a separate thread will # keep these writes of the symbol pointer on a separate stack that will be garbage # collected after Thread#join. Thread.new do test = %( {{ "some_string" | a_bad_filter }} ) Template.parse(test).render! nil end.join GC.start assert_equal([], Symbol.all_symbols - current_symbols) end def test_does_not_add_drop_methods_to_symbol_table current_symbols = Symbol.all_symbols assigns = { 'drop' => Drop.new } assert_equal("", Template.parse("{{ drop.custom_method_1 }}", assigns).render!) assert_equal("", Template.parse("{{ drop.custom_method_2 }}", assigns).render!) assert_equal("", Template.parse("{{ drop.custom_method_3 }}", assigns).render!) assert_equal([], Symbol.all_symbols - current_symbols) end def test_max_depth_nested_blocks_does_not_raise_exception depth = Liquid::Block::MAX_DEPTH code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth assert_equal("rendered", Template.parse(code).render!) end def test_more_than_max_depth_nested_blocks_raises_exception depth = Liquid::Block::MAX_DEPTH + 1 code = "{% if true %}" * depth + "rendered" + "{% endif %}" * depth assert_raises(Liquid::StackLevelError) do Template.parse(code).render! end end end # SecurityTest ================================================ FILE: test/integration/standard_filter_test.rb ================================================ # encoding: utf-8 # frozen_string_literal: true require 'test_helper' class TestThing attr_reader :foo def initialize @foo = 0 end def to_s "woot: #{@foo}" end def [](_whatever) to_s end def to_liquid @foo += 1 self end end class TestDrop < Liquid::Drop def initialize(value:) @value = value end attr_reader :value def registers "{#{@value.inspect}=>#{@context.registers[@value].inspect}}" end end class TestModel def initialize(value:) @value = value end def to_liquid TestDrop.new(value: @value) end end class TestEnumerable < Liquid::Drop include Enumerable def each(&block) [{ "foo" => 1, "bar" => 2 }, { "foo" => 2, "bar" => 1 }, { "foo" => 3, "bar" => 3 }].each(&block) end end class NumberLikeThing < Liquid::Drop def initialize(amount) @amount = amount end def to_number @amount end end class StandardFiltersTest < Minitest::Test Filters = Class.new(Liquid::StrainerTemplate) Filters.add_filter(Liquid::StandardFilters) include Liquid def setup @filters = Filters.new(Context.new) end def test_size assert_equal(3, @filters.size([1, 2, 3])) assert_equal(0, @filters.size([])) assert_equal(0, @filters.size(nil)) end def test_downcase assert_equal('testing', @filters.downcase("Testing")) assert_equal('', @filters.downcase(nil)) end def test_upcase assert_equal('TESTING', @filters.upcase("Testing")) assert_equal('', @filters.upcase(nil)) end def test_slice assert_equal('oob', @filters.slice('foobar', 1, 3)) assert_equal('oobar', @filters.slice('foobar', 1, 1000)) assert_equal('', @filters.slice('foobar', 1, 0)) assert_equal('o', @filters.slice('foobar', 1, 1)) assert_equal('bar', @filters.slice('foobar', 3, 3)) assert_equal('ar', @filters.slice('foobar', -2, 2)) assert_equal('ar', @filters.slice('foobar', -2, 1000)) assert_equal('r', @filters.slice('foobar', -1)) assert_equal('', @filters.slice(nil, 0)) assert_equal('', @filters.slice('foobar', 100, 10)) assert_equal('', @filters.slice('foobar', -100, 10)) assert_equal('oob', @filters.slice('foobar', '1', '3')) assert_raises(Liquid::ArgumentError) do @filters.slice('foobar', nil) end assert_raises(Liquid::ArgumentError) do @filters.slice('foobar', 0, "") end assert_equal("", @filters.slice("foobar", 0, -(1 << 64))) assert_equal("foobar", @filters.slice("foobar", 0, 1 << 63)) assert_equal("", @filters.slice("foobar", 1 << 63, 6)) assert_equal("", @filters.slice("foobar", -(1 << 63), 6)) end def test_slice_on_arrays input = 'foobar'.split('') assert_equal(%w(o o b), @filters.slice(input, 1, 3)) assert_equal(%w(o o b a r), @filters.slice(input, 1, 1000)) assert_equal(%w(), @filters.slice(input, 1, 0)) assert_equal(%w(o), @filters.slice(input, 1, 1)) assert_equal(%w(b a r), @filters.slice(input, 3, 3)) assert_equal(%w(a r), @filters.slice(input, -2, 2)) assert_equal(%w(a r), @filters.slice(input, -2, 1000)) assert_equal(%w(r), @filters.slice(input, -1)) assert_equal(%w(), @filters.slice(input, 100, 10)) assert_equal(%w(), @filters.slice(input, -100, 10)) assert_equal([], @filters.slice(input, 0, -(1 << 64))) assert_equal(input, @filters.slice(input, 0, 1 << 63)) assert_equal([], @filters.slice(input, 1 << 63, 6)) assert_equal([], @filters.slice(input, -(1 << 63), 6)) end def test_find_on_empty_array assert_nil(@filters.find([], 'foo', 'bar')) end def test_find_index_on_empty_array assert_nil(@filters.find_index([], 'foo', 'bar')) end def test_has_on_empty_array refute(@filters.has([], 'foo', 'bar')) end def test_truncate assert_equal('1234...', @filters.truncate('1234567890', 7)) assert_equal('1234567890', @filters.truncate('1234567890', 20)) assert_equal('...', @filters.truncate('1234567890', 0)) assert_equal('1234567890', @filters.truncate('1234567890')) assert_equal("测试...", @filters.truncate("测试测试测试测试", 5)) assert_equal('12341', @filters.truncate("1234567890", 5, 1)) assert_equal("foobar", @filters.truncate("foobar", 1 << 63)) assert_equal("...", @filters.truncate("foobar", -(1 << 63))) end def test_split assert_equal(['12', '34'], @filters.split('12~34', '~')) assert_equal(['A? ', ' ,Z'], @filters.split('A? ~ ~ ~ ,Z', '~ ~ ~')) assert_equal(['A?Z'], @filters.split('A?Z', '~')) assert_equal([], @filters.split(nil, ' ')) assert_equal(['A', 'Z'], @filters.split('A1Z', 1)) end def test_squish_filter assert_equal("foo bar boo", Liquid::Template.parse(%({{ " foo bar \t boo " | squish }})).render) assert_equal("", Liquid::Template.parse('{{ nil | squish }}').render) assert_equal("", Liquid::Template.parse('{{ " " | squish }}').render) end def test_escape assert_equal('<strong>', @filters.escape('')) assert_equal('1', @filters.escape(1)) assert_equal('2001-02-03', @filters.escape(Date.new(2001, 2, 3))) assert_nil(@filters.escape(nil)) end def test_h assert_equal('<strong>', @filters.h('')) assert_equal('1', @filters.h(1)) assert_equal('2001-02-03', @filters.h(Date.new(2001, 2, 3))) assert_nil(@filters.h(nil)) end def test_escape_once assert_equal('<strong>Hulk</strong>', @filters.escape_once('<strong>Hulk')) end def test_base64_encode assert_equal('b25lIHR3byB0aHJlZQ==', @filters.base64_encode('one two three')) assert_equal('', @filters.base64_encode(nil)) end def test_base64_decode decoded = @filters.base64_decode('b25lIHR3byB0aHJlZQ==') assert_equal('one two three', decoded) assert_equal(Encoding::UTF_8, decoded.encoding) decoded = @filters.base64_decode('4pyF') assert_equal('✅', decoded) assert_equal(Encoding::UTF_8, decoded.encoding) decoded = @filters.base64_decode("/w==") assert_equal(Encoding::ASCII_8BIT, decoded.encoding) assert_equal((+"\xFF").force_encoding(Encoding::ASCII_8BIT), decoded) exception = assert_raises(Liquid::ArgumentError) do @filters.base64_decode("invalidbase64") end assert_equal('Liquid error: invalid base64 provided to base64_decode', exception.message) end def test_base64_url_safe_encode assert_equal( 'YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8', @filters.base64_url_safe_encode('abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|'), ) assert_equal('', @filters.base64_url_safe_encode(nil)) end def test_base64_url_safe_decode decoded = @filters.base64_url_safe_decode('YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXogQUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVogMTIzNDU2Nzg5MCAhQCMkJV4mKigpLT1fKy8_Ljo7W117fVx8') assert_equal( 'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ 1234567890 !@#$%^&*()-=_+/?.:;[]{}\|', decoded, ) assert_equal(Encoding::UTF_8, decoded.encoding) decoded = @filters.base64_url_safe_decode('4pyF') assert_equal('✅', decoded) assert_equal(Encoding::UTF_8, decoded.encoding) decoded = @filters.base64_url_safe_decode("_w==") assert_equal(Encoding::ASCII_8BIT, decoded.encoding) assert_equal((+"\xFF").force_encoding(Encoding::ASCII_8BIT), decoded) exception = assert_raises(Liquid::ArgumentError) do @filters.base64_url_safe_decode("invalidbase64") end assert_equal('Liquid error: invalid base64 provided to base64_url_safe_decode', exception.message) end def test_url_encode assert_equal('foo%2B1%40example.com', @filters.url_encode('foo+1@example.com')) assert_equal('1', @filters.url_encode(1)) assert_equal('2001-02-03', @filters.url_encode(Date.new(2001, 2, 3))) assert_nil(@filters.url_encode(nil)) end def test_url_decode assert_equal('foo bar', @filters.url_decode('foo+bar')) assert_equal('foo bar', @filters.url_decode('foo%20bar')) assert_equal('foo+1@example.com', @filters.url_decode('foo%2B1%40example.com')) assert_equal('1', @filters.url_decode(1)) assert_equal('2001-02-03', @filters.url_decode(Date.new(2001, 2, 3))) assert_nil(@filters.url_decode(nil)) exception = assert_raises(Liquid::ArgumentError) do @filters.url_decode('%ff') end assert_equal('Liquid error: invalid byte sequence in UTF-8', exception.message) end def test_truncatewords assert_equal('one two three', @filters.truncatewords('one two three', 4)) assert_equal('one two...', @filters.truncatewords('one two three', 2)) assert_equal('one two three', @filters.truncatewords('one two three')) assert_equal( 'Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13”...', @filters.truncatewords('Two small (13” x 5.5” x 10” high) baskets fit inside one large basket (13” x 16” x 10.5” high) with cover.', 15), ) assert_equal("测试测试测试测试", @filters.truncatewords('测试测试测试测试', 5)) assert_equal('one two1', @filters.truncatewords("one two three", 2, 1)) assert_equal('one two three...', @filters.truncatewords("one two\tthree\nfour", 3)) assert_equal('one two...', @filters.truncatewords("one two three four", 2)) assert_equal('one...', @filters.truncatewords("one two three four", 0)) assert_equal('one two three four', @filters.truncatewords("one two three four", 1 << 31)) assert_equal('one...', @filters.truncatewords("one two three four", -(1 << 32))) end def test_strip_html assert_equal('test', @filters.strip_html("
test
")) assert_equal('test', @filters.strip_html("
test
")) assert_equal('', @filters.strip_html("")) assert_equal('', @filters.strip_html("")) assert_equal('test', @filters.strip_html("test")) assert_equal('test', @filters.strip_html("test")) assert_equal('', @filters.strip_html(nil)) # Quirk of the existing implementation assert_equal('foo;', @filters.strip_html("<<")) end def test_join assert_equal('1 2 3 4', @filters.join([1, 2, 3, 4])) assert_equal('1 - 2 - 3 - 4', @filters.join([1, 2, 3, 4], ' - ')) assert_equal('1121314', @filters.join([1, 2, 3, 4], 1)) end def test_join_calls_to_liquid_on_each_element assert_equal('i did it, i did it', @filters.join([CustomToLiquidDrop.new('i did it'), CustomToLiquidDrop.new('i did it')], ", ")) end def test_sort assert_equal([1, 2, 3, 4], @filters.sort([4, 3, 2, 1])) assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], @filters.sort([{ "a" => 4 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a")) end def test_sort_with_nils assert_equal([1, 2, 3, 4, nil], @filters.sort([nil, 4, 3, 2, 1])) assert_equal([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }, {}], @filters.sort([{ "a" => 4 }, { "a" => 3 }, {}, { "a" => 1 }, { "a" => 2 }], "a")) end def test_sort_when_property_is_sometimes_missing_puts_nils_last input = [ { "price" => 4, "handle" => "alpha" }, { "handle" => "beta" }, { "price" => 1, "handle" => "gamma" }, { "handle" => "delta" }, { "price" => 2, "handle" => "epsilon" }, ] expectation = [ { "price" => 1, "handle" => "gamma" }, { "price" => 2, "handle" => "epsilon" }, { "price" => 4, "handle" => "alpha" }, { "handle" => "beta" }, { "handle" => "delta" }, ] assert_equal(expectation, @filters.sort(input, "price")) end def test_sort_natural assert_equal(["a", "B", "c", "D"], @filters.sort_natural(["c", "D", "a", "B"])) assert_equal([{ "a" => "a" }, { "a" => "B" }, { "a" => "c" }, { "a" => "D" }], @filters.sort_natural([{ "a" => "D" }, { "a" => "c" }, { "a" => "a" }, { "a" => "B" }], "a")) end def test_sort_natural_with_nils assert_equal(["a", "B", "c", "D", nil], @filters.sort_natural([nil, "c", "D", "a", "B"])) assert_equal([{ "a" => "a" }, { "a" => "B" }, { "a" => "c" }, { "a" => "D" }, {}], @filters.sort_natural([{ "a" => "D" }, { "a" => "c" }, {}, { "a" => "a" }, { "a" => "B" }], "a")) end def test_sort_natural_when_property_is_sometimes_missing_puts_nils_last input = [ { "price" => "4", "handle" => "alpha" }, { "handle" => "beta" }, { "price" => "1", "handle" => "gamma" }, { "handle" => "delta" }, { "price" => 2, "handle" => "epsilon" }, ] expectation = [ { "price" => "1", "handle" => "gamma" }, { "price" => 2, "handle" => "epsilon" }, { "price" => "4", "handle" => "alpha" }, { "handle" => "beta" }, { "handle" => "delta" }, ] assert_equal(expectation, @filters.sort_natural(input, "price")) end def test_sort_natural_case_check input = [ { "key" => "X" }, { "key" => "Y" }, { "key" => "Z" }, { "fake" => "t" }, { "key" => "a" }, { "key" => "b" }, { "key" => "c" }, ] expectation = [ { "key" => "a" }, { "key" => "b" }, { "key" => "c" }, { "key" => "X" }, { "key" => "Y" }, { "key" => "Z" }, { "fake" => "t" }, ] assert_equal(expectation, @filters.sort_natural(input, "key")) assert_equal(["a", "b", "c", "X", "Y", "Z"], @filters.sort_natural(["X", "Y", "Z", "a", "b", "c"])) end def test_sort_empty_array assert_equal([], @filters.sort([], "a")) end def test_sort_invalid_property foo = [ [1], [2], [3], ] assert_raises(Liquid::ArgumentError) do @filters.sort(foo, "bar") end end def test_sort_natural_empty_array assert_equal([], @filters.sort_natural([], "a")) end def test_sort_natural_invalid_property foo = [ [1], [2], [3], ] assert_raises(Liquid::ArgumentError) do @filters.sort_natural(foo, "bar") end end def test_legacy_sort_hash assert_equal([{ a: 1, b: 2 }], @filters.sort(a: 1, b: 2)) end def test_numerical_vs_lexicographical_sort assert_equal([2, 10], @filters.sort([10, 2])) assert_equal([{ "a" => 2 }, { "a" => 10 }], @filters.sort([{ "a" => 10 }, { "a" => 2 }], "a")) assert_equal(["10", "2"], @filters.sort(["10", "2"])) assert_equal([{ "a" => "10" }, { "a" => "2" }], @filters.sort([{ "a" => "10" }, { "a" => "2" }], "a")) end def test_uniq assert_equal(["foo"], @filters.uniq("foo")) assert_equal([1, 3, 2, 4], @filters.uniq([1, 1, 3, 2, 3, 1, 4, 3, 2, 1])) assert_equal([{ "a" => 1 }, { "a" => 3 }, { "a" => 2 }], @filters.uniq([{ "a" => 1 }, { "a" => 3 }, { "a" => 1 }, { "a" => 2 }], "a")) test_drop = TestDrop.new(value: "test") test_drop_alternate = TestDrop.new(value: "test") assert_equal([test_drop], @filters.uniq([test_drop, test_drop_alternate], 'value')) end def test_uniq_empty_array assert_equal([], @filters.uniq([], "a")) end def test_uniq_invalid_property foo = [ [1], [2], [3], ] assert_raises(Liquid::ArgumentError) do @filters.uniq(foo, "bar") end end def test_compact_empty_array assert_equal([], @filters.compact([], "a")) end def test_compact_invalid_property foo = [ [1], [2], [3], ] assert_raises(Liquid::ArgumentError) do @filters.compact(foo, "bar") end end def test_reverse assert_equal([4, 3, 2, 1], @filters.reverse([1, 2, 3, 4])) end def test_legacy_reverse_hash assert_equal([{ a: 1, b: 2 }], @filters.reverse(a: 1, b: 2)) end def test_map assert_equal([1, 2, 3, 4], @filters.map([{ "a" => 1 }, { "a" => 2 }, { "a" => 3 }, { "a" => 4 }], 'a')) assert_template_result( 'abc', "{{ ary | map:'foo' | map:'bar' }}", { 'ary' => [{ 'foo' => { 'bar' => 'a' } }, { 'foo' => { 'bar' => 'b' } }, { 'foo' => { 'bar' => 'c' } }] }, ) end def test_map_doesnt_call_arbitrary_stuff assert_template_result("", '{{ "foo" | map: "__id__" }}') assert_template_result("", '{{ "foo" | map: "inspect" }}') end def test_map_calls_to_liquid t = TestThing.new assert_template_result("woot: 1", '{{ foo | map: "whatever" }}', { "foo" => [t] }) end def test_map_calls_context= model = TestModel.new(value: :test) template = Template.parse('{{ foo | map: "registers" }}') template.registers[:test] = 1234 template.assigns['foo'] = [model] assert_template_result("{:test=>1234}", template.render!) end def test_map_on_hashes assert_template_result( "4217", '{{ thing | map: "foo" | map: "bar" }}', { "thing" => { "foo" => [{ "bar" => 42 }, { "bar" => 17 }] } }, ) end def test_legacy_map_on_hashes_with_dynamic_key template = "{% assign key = 'foo' %}{{ thing | map: key | map: 'bar' }}" hash = { "foo" => { "bar" => 42 } } assert_template_result("42", template, { "thing" => hash }) end def test_sort_calls_to_liquid t = TestThing.new Liquid::Template.parse('{{ foo | sort: "whatever" }}').render("foo" => [t]) assert(t.foo > 0) end def test_map_over_proc drop = TestDrop.new(value: "testfoo") p = proc { drop } output = Liquid::Template.parse('{{ procs | map: "value" }}').render!({ "procs" => [p] }) assert_equal("testfoo", output) end def test_map_over_drops_returning_procs drops = [ { "proc" => -> { "foo" }, }, { "proc" => -> { "bar" }, }, ] output = Liquid::Template.parse('{{ drops | map: "proc" }}').render!({ "drops" => drops }) assert_equal("foobar", output) end def test_map_works_on_enumerables output = Liquid::Template.parse('{{ foo | map: "foo" }}').render!({ "foo" => TestEnumerable.new }) assert_equal("123", output) end def test_map_returns_empty_on_2d_input_array foo = [ [1], [2], [3], ] assert_raises(Liquid::ArgumentError) do @filters.map(foo, "bar") end end def test_map_with_value_property array = [ { "handle" => "alpha", "value" => "A" }, { "handle" => "beta", "value" => "B" }, { "handle" => "gamma", "value" => "C" } ] assert_template_result("A B C", "{{ array | map: 'value' | join: ' ' }}", { "array" => array }) end def test_map_returns_input_with_no_property foo = [ [1], [2], [3], ] assert_raises(Liquid::ArgumentError) do @filters.map(foo, nil) end end def test_sort_works_on_enumerables assert_template_result("213", '{{ foo | sort: "bar" | map: "foo" }}', { "foo" => TestEnumerable.new }) end def test_first_and_last_call_to_liquid assert_template_result('foobar', '{{ foo | first }}', { 'foo' => [ThingWithToLiquid.new] }) assert_template_result('foobar', '{{ foo | last }}', { 'foo' => [ThingWithToLiquid.new] }) end def test_truncate_calls_to_liquid assert_template_result("wo...", '{{ foo | truncate: 5 }}', { "foo" => TestThing.new }) end def test_date assert_equal('May', @filters.date(Time.parse("2006-05-05 10:00:00"), "%B")) assert_equal('June', @filters.date(Time.parse("2006-06-05 10:00:00"), "%B")) assert_equal('July', @filters.date(Time.parse("2006-07-05 10:00:00"), "%B")) assert_equal('May', @filters.date("2006-05-05 10:00:00", "%B")) assert_equal('June', @filters.date("2006-06-05 10:00:00", "%B")) assert_equal('July', @filters.date("2006-07-05 10:00:00", "%B")) assert_equal('2006-07-05 10:00:00', @filters.date("2006-07-05 10:00:00", "")) assert_equal('2006-07-05 10:00:00', @filters.date("2006-07-05 10:00:00", "")) assert_equal('2006-07-05 10:00:00', @filters.date("2006-07-05 10:00:00", "")) assert_equal('2006-07-05 10:00:00', @filters.date("2006-07-05 10:00:00", nil)) assert_equal('07/05/2006', @filters.date("2006-07-05 10:00:00", "%m/%d/%Y")) assert_equal("07/16/2004", @filters.date("Fri Jul 16 01:00:00 2004", "%m/%d/%Y")) assert_equal(Date.today.year.to_s, @filters.date('now', '%Y')) assert_equal(Date.today.year.to_s, @filters.date('today', '%Y')) assert_equal(Date.today.year.to_s, @filters.date('Today', '%Y')) assert_nil(@filters.date(nil, "%B")) assert_equal('', @filters.date('', "%B")) with_timezone("UTC") do assert_equal("07/05/2006", @filters.date(1152098955, "%m/%d/%Y")) assert_equal("07/05/2006", @filters.date("1152098955", "%m/%d/%Y")) end end def test_first_last assert_equal(1, @filters.first([1, 2, 3])) assert_equal(3, @filters.last([1, 2, 3])) assert_nil(@filters.first([])) assert_nil(@filters.last([])) end def test_first_last_on_strings # Ruby's String class does not have first/last methods by default. # ActiveSupport adds String#first and String#last to return the first/last character. # Liquid must work without ActiveSupport, so the first/last filters handle strings specially. # # This enables template patterns like: # {{ product.title | first }} => "S" (for "Snowboard") # {{ customer.name | last }} => "h" (for "Smith") # # Note: ActiveSupport returns "" for empty strings, not nil. assert_equal('f', @filters.first('foo')) assert_equal('o', @filters.last('foo')) assert_equal('', @filters.first('')) assert_equal('', @filters.last('')) end def test_first_last_on_unicode_strings # Unicode strings should return the first/last grapheme cluster (character), # not the first/last byte. Ruby's String#[] handles this correctly with index 0/-1. # This ensures international text works properly: # {{ korean_name | first }} => "고" (not a partial byte sequence) assert_equal('고', @filters.first('고스트빈')) assert_equal('빈', @filters.last('고스트빈')) end def test_first_last_on_strings_via_template # Integration test to verify the filter works end-to-end in templates. # Empty strings return empty output (nil renders as empty string). assert_template_result('f', '{{ name | first }}', { 'name' => 'foo' }) assert_template_result('o', '{{ name | last }}', { 'name' => 'foo' }) assert_template_result('', '{{ name | first }}', { 'name' => '' }) assert_template_result('', '{{ name | last }}', { 'name' => '' }) end def test_replace assert_equal('b b b b', @filters.replace('a a a a', 'a', 'b')) assert_equal('2 2 2 2', @filters.replace('1 1 1 1', 1, 2)) assert_equal('1 1 1 1', @filters.replace('1 1 1 1', 2, 3)) assert_template_result('2 2 2 2', "{{ '1 1 1 1' | replace: '1', 2 }}") assert_equal('b a a a', @filters.replace_first('a a a a', 'a', 'b')) assert_equal('2 1 1 1', @filters.replace_first('1 1 1 1', 1, 2)) assert_equal('1 1 1 1', @filters.replace_first('1 1 1 1', 2, 3)) assert_template_result('2 1 1 1', "{{ '1 1 1 1' | replace_first: '1', 2 }}") assert_equal('a a a b', @filters.replace_last('a a a a', 'a', 'b')) assert_equal('1 1 1 2', @filters.replace_last('1 1 1 1', 1, 2)) assert_equal('1 1 1 1', @filters.replace_last('1 1 1 1', 2, 3)) assert_template_result('1 1 1 2', "{{ '1 1 1 1' | replace_last: '1', 2 }}") end def test_remove assert_equal(' ', @filters.remove("a a a a", 'a')) assert_template_result(' ', "{{ '1 1 1 1' | remove: 1 }}") assert_equal('b a a', @filters.remove_first("a b a a", 'a ')) assert_template_result(' 1 1 1', "{{ '1 1 1 1' | remove_first: 1 }}") assert_equal('a a b', @filters.remove_last("a a b a", ' a')) assert_template_result('1 1 1 ', "{{ '1 1 1 1' | remove_last: 1 }}") end def test_pipes_in_string_arguments assert_template_result('foobar', "{{ 'foo|bar' | remove: '|' }}") end def test_strip assert_template_result('ab c', "{{ source | strip }}", { 'source' => " ab c " }) assert_template_result('ab c', "{{ source | strip }}", { 'source' => " \tab c \n \t" }) end def test_lstrip assert_template_result('ab c ', "{{ source | lstrip }}", { 'source' => " ab c " }) assert_template_result("ab c \n \t", "{{ source | lstrip }}", { 'source' => " \tab c \n \t" }) end def test_rstrip assert_template_result(" ab c", "{{ source | rstrip }}", { 'source' => " ab c " }) assert_template_result(" \tab c", "{{ source | rstrip }}", { 'source' => " \tab c \n \t" }) end def test_strip_newlines assert_template_result('abc', "{{ source | strip_newlines }}", { 'source' => "a\nb\nc" }) assert_template_result('abc', "{{ source | strip_newlines }}", { 'source' => "a\r\nb\nc" }) end def test_newlines_to_br assert_template_result("a
\nb
\nc", "{{ source | newline_to_br }}", { 'source' => "a\nb\nc" }) assert_template_result("a
\nb
\nc", "{{ source | newline_to_br }}", { 'source' => "a\r\nb\nc" }) end def test_plus assert_template_result("2", "{{ 1 | plus:1 }}") assert_template_result("2.0", "{{ '1' | plus:'1.0' }}") assert_template_result("5", "{{ price | plus:'2' }}", { 'price' => NumberLikeThing.new(3) }) end def test_minus assert_template_result("4", "{{ input | minus:operand }}", { 'input' => 5, 'operand' => 1 }) assert_template_result("2.3", "{{ '4.3' | minus:'2' }}") assert_template_result("5", "{{ price | minus:'2' }}", { 'price' => NumberLikeThing.new(7) }) end def test_abs assert_template_result("17", "{{ 17 | abs }}") assert_template_result("17", "{{ -17 | abs }}") assert_template_result("17", "{{ '17' | abs }}") assert_template_result("17", "{{ '-17' | abs }}") assert_template_result("0", "{{ 0 | abs }}") assert_template_result("0", "{{ '0' | abs }}") assert_template_result("17.42", "{{ 17.42 | abs }}") assert_template_result("17.42", "{{ -17.42 | abs }}") assert_template_result("17.42", "{{ '17.42' | abs }}") assert_template_result("17.42", "{{ '-17.42' | abs }}") end def test_times assert_template_result("12", "{{ 3 | times:4 }}") assert_template_result("0", "{{ 'foo' | times:4 }}") assert_template_result("6", "{{ '2.1' | times:3 | replace: '.','-' | plus:0}}") assert_template_result("7.25", "{{ 0.0725 | times:100 }}") assert_template_result("-7.25", '{{ "-0.0725" | times:100 }}') assert_template_result("7.25", '{{ "-0.0725" | times: -100 }}') assert_template_result("4", "{{ price | times:2 }}", { 'price' => NumberLikeThing.new(2) }) end def test_divided_by assert_template_result("4", "{{ 12 | divided_by:3 }}") assert_template_result("4", "{{ 14 | divided_by:3 }}") assert_template_result("5", "{{ 15 | divided_by:3 }}") assert_equal("Liquid error: divided by 0", Template.parse("{{ 5 | divided_by:0 }}").render) assert_template_result("0.5", "{{ 2.0 | divided_by:4 }}") assert_raises(Liquid::ZeroDivisionError) do assert_template_result("4", "{{ 1 | modulo: 0 }}") end assert_template_result("5", "{{ price | divided_by:2 }}", { 'price' => NumberLikeThing.new(10) }) end def test_modulo assert_template_result("1", "{{ 3 | modulo:2 }}") assert_raises(Liquid::ZeroDivisionError) do assert_template_result("4", "{{ 1 | modulo: 0 }}") end assert_template_result("1", "{{ price | modulo:2 }}", { 'price' => NumberLikeThing.new(3) }) end def test_round assert_template_result("5", "{{ input | round }}", { 'input' => 4.6 }) assert_template_result("4", "{{ '4.3' | round }}") assert_template_result("4.56", "{{ input | round: 2 }}", { 'input' => 4.5612 }) assert_raises(Liquid::FloatDomainError) do assert_template_result("4", "{{ 1.0 | divided_by: 0.0 | round }}") end assert_template_result("5", "{{ price | round }}", { 'price' => NumberLikeThing.new(4.6) }) assert_template_result("4", "{{ price | round }}", { 'price' => NumberLikeThing.new(4.3) }) end def test_ceil assert_template_result("5", "{{ input | ceil }}", { 'input' => 4.6 }) assert_template_result("5", "{{ '4.3' | ceil }}") assert_raises(Liquid::FloatDomainError) do assert_template_result("4", "{{ 1.0 | divided_by: 0.0 | ceil }}") end assert_template_result("5", "{{ price | ceil }}", { 'price' => NumberLikeThing.new(4.6) }) end def test_floor assert_template_result("4", "{{ input | floor }}", { 'input' => 4.6 }) assert_template_result("4", "{{ '4.3' | floor }}") assert_raises(Liquid::FloatDomainError) do assert_template_result("4", "{{ 1.0 | divided_by: 0.0 | floor }}") end assert_template_result("5", "{{ price | floor }}", { 'price' => NumberLikeThing.new(5.4) }) end def test_at_most assert_template_result("4", "{{ 5 | at_most:4 }}") assert_template_result("5", "{{ 5 | at_most:5 }}") assert_template_result("5", "{{ 5 | at_most:6 }}") assert_template_result("4.5", "{{ 4.5 | at_most:5 }}") assert_template_result("5", "{{ width | at_most:5 }}", { 'width' => NumberLikeThing.new(6) }) assert_template_result("4", "{{ width | at_most:5 }}", { 'width' => NumberLikeThing.new(4) }) assert_template_result("4", "{{ 5 | at_most: width }}", { 'width' => NumberLikeThing.new(4) }) end def test_at_least assert_template_result("5", "{{ 5 | at_least:4 }}") assert_template_result("5", "{{ 5 | at_least:5 }}") assert_template_result("6", "{{ 5 | at_least:6 }}") assert_template_result("5", "{{ 4.5 | at_least:5 }}") assert_template_result("6", "{{ width | at_least:5 }}", { 'width' => NumberLikeThing.new(6) }) assert_template_result("5", "{{ width | at_least:5 }}", { 'width' => NumberLikeThing.new(4) }) assert_template_result("6", "{{ 5 | at_least: width }}", { 'width' => NumberLikeThing.new(6) }) end def test_append assigns = { 'a' => 'bc', 'b' => 'd' } assert_template_result('bcd', "{{ a | append: 'd'}}", assigns) assert_template_result('bcd', "{{ a | append: b}}", assigns) end def test_concat assert_equal([1, 2, 3, 4], @filters.concat([1, 2], [3, 4])) assert_equal([1, 2, 'a'], @filters.concat([1, 2], ['a'])) assert_equal([1, 2, 10], @filters.concat([1, 2], [10])) assert_raises(Liquid::ArgumentError, "concat filter requires an array argument") do @filters.concat([1, 2], 10) end end def test_prepend assigns = { 'a' => 'bc', 'b' => 'a' } assert_template_result('abc', "{{ a | prepend: 'a'}}", assigns) assert_template_result('abc', "{{ a | prepend: b}}", assigns) end def test_default assert_equal("foo", @filters.default("foo", "bar")) assert_equal("bar", @filters.default(nil, "bar")) assert_equal("bar", @filters.default("", "bar")) assert_equal("bar", @filters.default(false, "bar")) assert_equal("bar", @filters.default([], "bar")) assert_equal("bar", @filters.default({}, "bar")) assert_template_result('bar', "{{ false | default: 'bar' }}") assert_template_result('bar', "{{ drop | default: 'bar' }}", { 'drop' => BooleanDrop.new(false) }) assert_template_result('Yay', "{{ drop | default: 'bar' }}", { 'drop' => BooleanDrop.new(true) }) end def test_default_handle_false assert_equal("foo", @filters.default("foo", "bar", "allow_false" => true)) assert_equal("bar", @filters.default(nil, "bar", "allow_false" => true)) assert_equal("bar", @filters.default("", "bar", "allow_false" => true)) assert_equal(false, @filters.default(false, "bar", "allow_false" => true)) assert_equal("bar", @filters.default([], "bar", "allow_false" => true)) assert_equal("bar", @filters.default({}, "bar", "allow_false" => true)) assert_template_result('false', "{{ false | default: 'bar', allow_false: true }}") assert_template_result('Nay', "{{ drop | default: 'bar', allow_false: true }}", { 'drop' => BooleanDrop.new(false) }) assert_template_result('Yay', "{{ drop | default: 'bar', allow_false: true }}", { 'drop' => BooleanDrop.new(true) }) end def test_cannot_access_private_methods assert_template_result('a', "{{ 'a' | to_number }}") end def test_date_raises_nothing assert_template_result('', "{{ '' | date: '%D' }}") assert_template_result('abc', "{{ 'abc' | date: '%D' }}") end def test_reject array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] template = "{{ array | reject: 'ok' | map: 'handle' | join: ' ' }}" expected_output = "beta gamma" assert_template_result(expected_output, template, { "array" => array }) end def test_reject_with_value array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] template = "{{ array | reject: 'ok', true | map: 'handle' | join: ' ' }}" expected_output = "beta gamma" assert_template_result(expected_output, template, { "array" => array }) end def test_reject_with_false_value array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] template = "{{ array | reject: 'ok', false | map: 'handle' | join: ' ' }}" expected_output = "alpha delta" assert_template_result(expected_output, template, { "array" => array }) end def test_has array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => false }, ] expected_output = "true" assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array }) assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array }) end def test_has_when_does_not_have_it array = [ { "handle" => "alpha", "ok" => false }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => false }, ] expected_output = "false" assert_template_result(expected_output, "{{ array | has: 'ok' }}", { "array" => array }) assert_template_result(expected_output, "{{ array | has: 'ok', true }}", { "array" => array }) end def test_has_with_empty_arrays template = <<~LIQUID {%- assign has_product = products | has: 'title.content', 'Not found' -%} {%- unless has_product -%} Product not found. {%- endunless -%} LIQUID expected_output = "Product not found." assert_template_result(expected_output, template, { "products" => [] }) end def test_has_with_false_value array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] template = "{{ array | has: 'ok', false }}" expected_output = "true" assert_template_result(expected_output, template, { "array" => array }) end def test_has_with_false_value_when_does_not_have_it array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => true }, { "handle" => "gamma", "ok" => true }, { "handle" => "delta", "ok" => true }, ] template = "{{ array | has: 'ok', false }}" expected_output = "false" assert_template_result(expected_output, template, { "array" => array }) end def test_find_with_value products = [ { "title" => "Pro goggles", "price" => 1299 }, { "title" => "Thermal gloves", "price" => 1499 }, { "title" => "Alpine jacket", "price" => 3999 }, { "title" => "Mountain boots", "price" => 3899 }, { "title" => "Safety helmet", "price" => 1999 } ] template = <<~LIQUID {%- assign product = products | find: 'price', 3999 -%} {{- product.title -}} LIQUID expected_output = "Alpine jacket" assert_template_result(expected_output, template, { "products" => products }) end def test_find_with_empty_arrays template = <<~LIQUID {%- assign product = products | find: 'title.content', 'Not found' -%} {%- unless product -%} Product not found. {%- endunless -%} LIQUID expected_output = "Product not found." assert_template_result(expected_output, template, { "products" => [] }) end def test_find_index_with_value products = [ { "title" => "Pro goggles", "price" => 1299 }, { "title" => "Thermal gloves", "price" => 1499 }, { "title" => "Alpine jacket", "price" => 3999 }, { "title" => "Mountain boots", "price" => 3899 }, { "title" => "Safety helmet", "price" => 1999 } ] template = <<~LIQUID {%- assign index = products | find_index: 'price', 3999 -%} {{- index -}} LIQUID expected_output = "2" assert_template_result(expected_output, template, { "products" => products }) end def test_find_index_with_empty_arrays template = <<~LIQUID {%- assign index = products | find_index: 'title.content', 'Not found' -%} {%- unless index -%} Index not found. {%- endunless -%} LIQUID expected_output = "Index not found." assert_template_result(expected_output, template, { "products" => [] }) end def test_where array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] template = "{{ array | where: 'ok' | map: 'handle' | join: ' ' }}" expected_output = "alpha delta" assert_template_result(expected_output, template, { "array" => array }) end def test_where_with_empty_string_is_a_no_op environment = { "array" => ["alpha", "beta", "gamma"] } expected_output = "alpha beta gamma" template = "{{ array | where: '' | join: ' ' }}" assert_template_result(expected_output, template, environment) end def test_where_with_nil_is_a_no_op environment = { "array" => ["alpha", "beta", "gamma"] } template = "{{ array | where: nil | join: ' ' }}" assert_raises(Liquid::ArgumentError) do assert_template_result("alpha beta gamma", template, environment) end end def test_where_with_value array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] template = "{{ array | where: 'ok', true | map: 'handle' | join: ' ' }}" expected_output = "alpha delta" assert_template_result(expected_output, template, { "array" => array }) end def test_where_with_false_value array = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta", "ok" => false }, { "handle" => "gamma", "ok" => false }, { "handle" => "delta", "ok" => true }, ] template = "{{ array | where: 'ok', false | map: 'handle' | join: ' ' }}" expected_output = "beta gamma" assert_template_result(expected_output, template, { "array" => array }) end def test_where_string_keys input = [ "alpha", "beta", "gamma", "delta" ] expectation = [ "beta", ] assert_equal(expectation, @filters.where(input, "be")) end def test_where_no_key_set input = [ { "handle" => "alpha", "ok" => true }, { "handle" => "beta" }, { "handle" => "gamma" }, { "handle" => "delta", "ok" => true }, ] expectation = [ { "handle" => "alpha", "ok" => true }, { "handle" => "delta", "ok" => true }, ] assert_equal(expectation, @filters.where(input, "ok", true)) assert_equal(expectation, @filters.where(input, "ok")) end def test_where_non_array_map_input assert_equal([{ "a" => "ok" }], @filters.where({ "a" => "ok" }, "a", "ok")) assert_equal([], @filters.where({ "a" => "not ok" }, "a", "ok")) end def test_where_indexable_but_non_map_value assert_raises(Liquid::ArgumentError) { @filters.where(1, "ok", true) } assert_raises(Liquid::ArgumentError) { @filters.where(1, "ok") } end def test_where_non_boolean_value input = [ { "message" => "Bonjour!", "language" => "French" }, { "message" => "Hello!", "language" => "English" }, { "message" => "Hallo!", "language" => "German" }, ] assert_equal([{ "message" => "Bonjour!", "language" => "French" }], @filters.where(input, "language", "French")) assert_equal([{ "message" => "Hallo!", "language" => "German" }], @filters.where(input, "language", "German")) assert_equal([{ "message" => "Hello!", "language" => "English" }], @filters.where(input, "language", "English")) end def test_where_array_of_only_unindexable_values assert_nil(@filters.where([nil], "ok", true)) assert_nil(@filters.where([nil], "ok")) end def test_all_filters_never_raise_non_liquid_exception test_drop = TestDrop.new(value: "test") test_drop.context = Context.new test_enum = TestEnumerable.new test_enum.context = Context.new test_types = [ "foo", 123, 0, 0.0, -1234.003030303, -99999999, 1234.38383000383830003838300, nil, true, false, TestThing.new, test_drop, test_enum, ["foo", "bar"], { "foo" => "bar" }, { foo: "bar" }, [{ "foo" => "bar" }, { "foo" => 123 }, { "foo" => nil }, { "foo" => true }, { "foo" => ["foo", "bar"] }], { 1 => "bar" }, ["foo", 123, nil, true, false, Drop, ["foo"], { foo: "bar" }], ] StandardFilters.public_instance_methods(false).each do |method| arg_count = @filters.method(method).arity arg_count *= -1 if arg_count < 0 test_types.repeated_permutation(arg_count) do |args| @filters.send(method, *args) rescue Liquid::Error nil end end end def test_where_no_target_value input = [ { "foo" => false }, { "foo" => true }, { "foo" => "for sure" }, { "bar" => true }, ] assert_equal([{ "foo" => true }, { "foo" => "for sure" }], @filters.where(input, "foo")) end def test_sum_with_all_numbers input = [1, 2] assert_equal(3, @filters.sum(input)) assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do @filters.sum(input, "quantity") end end def test_sum_with_numeric_strings input = [1, 2, "3", "4"] assert_equal(10, @filters.sum(input)) assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do @filters.sum(input, "quantity") end end def test_sum_with_nested_arrays input = [1, [2, [3, 4]]] assert_equal(10, @filters.sum(input)) assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do @filters.sum(input, "quantity") end end def test_sum_with_indexable_map_values input = [{ "quantity" => 1 }, { "quantity" => 2, "weight" => 3 }, { "weight" => 4 }] assert_equal(0, @filters.sum(input)) assert_equal(3, @filters.sum(input, "quantity")) assert_equal(7, @filters.sum(input, "weight")) assert_equal(0, @filters.sum(input, "subtotal")) end def test_sum_with_indexable_non_map_values input = [1, [2], "foo", { "quantity" => 3 }] assert_equal(3, @filters.sum(input)) assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do @filters.sum(input, "quantity") end end def test_sum_with_unindexable_values input = [1, true, nil, { "quantity" => 2 }] assert_equal(1, @filters.sum(input)) assert_raises(Liquid::ArgumentError, "cannot select the property 'quantity'") do @filters.sum(input, "quantity") end end def test_sum_without_property_calls_to_liquid t = TestThing.new Liquid::Template.parse('{{ foo | sum }}').render("foo" => [t]) assert(t.foo > 0) end def test_sum_with_property_calls_to_liquid_on_property_values t = TestThing.new Liquid::Template.parse('{{ foo | sum: "quantity" }}').render("foo" => [{ "quantity" => t }]) assert(t.foo > 0) end def test_sum_of_floats input = [0.1, 0.2, 0.3] assert_equal(0.6, @filters.sum(input)) assert_template_result("0.6", "{{ input | sum }}", { "input" => input }) end def test_sum_of_negative_floats input = [0.1, 0.2, -0.3] assert_equal(0.0, @filters.sum(input)) assert_template_result("0.0", "{{ input | sum }}", { "input" => input }) end def test_sum_with_float_strings input = [0.1, "0.2", "0.3"] assert_equal(0.6, @filters.sum(input)) assert_template_result("0.6", "{{ input | sum }}", { "input" => input }) end def test_sum_resulting_in_negative_float input = [0.1, -0.2, -0.3] assert_equal(-0.4, @filters.sum(input)) assert_template_result("-0.4", "{{ input | sum }}", { "input" => input }) end def test_sum_with_floats_and_indexable_map_values input = [{ "quantity" => 1 }, { "quantity" => 0.2, "weight" => -0.3 }, { "weight" => 0.4 }] assert_equal(0.0, @filters.sum(input)) assert_equal(1.2, @filters.sum(input, "quantity")) assert_equal(0.1, @filters.sum(input, "weight")) assert_equal(0.0, @filters.sum(input, "subtotal")) assert_template_result("0", "{{ input | sum }}", { "input" => input }) assert_template_result("1.2", "{{ input | sum: 'quantity' }}", { "input" => input }) assert_template_result("0.1", "{{ input | sum: 'weight' }}", { "input" => input }) assert_template_result("0", "{{ input | sum: 'subtotal' }}", { "input" => input }) end def test_sum_with_non_string_property input = [{ true => 1 }, { 1.0 => 0.2, 1 => -0.3 }, { 1..5 => 0.4 }] assert_equal(1, @filters.sum(input, true)) assert_equal(0.2, @filters.sum(input, 1.0)) assert_equal(-0.3, @filters.sum(input, 1)) assert_equal(0.4, @filters.sum(input, 1..5)) assert_equal(0, @filters.sum(input, nil)) assert_equal(0, @filters.sum(input, "")) end def test_uniq_with_to_liquid_value input = [StringDrop.new("foo"), StringDrop.new("bar"), "foo"] expected = [StringDrop.new("foo"), StringDrop.new("bar")] result = @filters.uniq(input) assert_equal(expected, result) end def test_uniq_with_to_liquid_value_pick_correct_classes input = ["foo", StringDrop.new("foo"), StringDrop.new("bar")] expected = [String, StringDrop] result = @filters.uniq(input).map(&:class) assert_equal(expected, result) end private def with_timezone(tz) old_tz = ENV['TZ'] ENV['TZ'] = tz yield ensure ENV['TZ'] = old_tz end end # StandardFiltersTest ================================================ FILE: test/integration/tag/disableable_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TagDisableableTest < Minitest::Test include Liquid module RenderTagName def render(_context) tag_name end end class Custom < Tag prepend Liquid::Tag::Disableable include RenderTagName end class Custom2 < Tag prepend Liquid::Tag::Disableable include RenderTagName end class DisableCustom < Block disable_tags "custom" end class DisableBoth < Block disable_tags "custom", "custom2" end def test_block_tag_disabling_nested_tag with_disableable_tags do with_custom_tag('disable', DisableCustom) do output = Template.parse('{% disable %}{% custom %};{% custom2 %}{% enddisable %}').render assert_equal('Liquid error: custom usage is not allowed in this context;custom2', output) end end end def test_block_tag_disabling_multiple_nested_tags with_disableable_tags do with_custom_tag('disable', DisableBoth) do output = Template.parse('{% disable %}{% custom %};{% custom2 %}{% enddisable %}').render assert_equal('Liquid error: custom usage is not allowed in this context;Liquid error: custom2 usage is not allowed in this context', output) end end end private def with_disableable_tags with_custom_tag('custom', Custom) do with_custom_tag('custom2', Custom2) do yield end end end end ================================================ FILE: test/integration/tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TagTest < Minitest::Test include Liquid def test_custom_tags_have_a_default_render_to_output_buffer_method_for_backwards_compatibility klass1 = Class.new(Tag) do def render(*) 'hello' end end with_custom_tag('blabla', klass1) do template = Liquid::Template.parse("{% blabla %}") assert_equal('hello', template.render) buf = +'' output = template.render({}, output: buf) assert_equal('hello', output) assert_equal('hello', buf) assert_equal(buf.object_id, output.object_id) end klass2 = Class.new(klass1) do def render(*) 'foo' + super + 'bar' end end with_custom_tag('blabla', klass2) do template = Liquid::Template.parse("{% blabla %}") assert_equal('foohellobar', template.render) buf = +'' output = template.render({}, output: buf) assert_equal('foohellobar', output) assert_equal('foohellobar', buf) assert_equal(buf.object_id, output.object_id) end end end ================================================ FILE: test/integration/tags/break_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class BreakTagTest < Minitest::Test include Liquid # tests that no weird errors are raised if break is called outside of a # block def test_break_with_no_block assigns = { 'i' => 1 } markup = 'before{% break %}after' expected = 'before' assert_template_result(expected, markup, assigns) end end ================================================ FILE: test/integration/tags/continue_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ContinueTagTest < Minitest::Test include Liquid # tests that no weird errors are raised if continue is called outside of a # block def test_continue_with_no_block assigns = {} markup = '{% continue %}' expected = '' assert_template_result(expected, markup, assigns) end end ================================================ FILE: test/integration/tags/cycle_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class CycleTagTest < Minitest::Test def test_simple_cycle_inside_for_loop template = <<~LIQUID {%- for i in (1..3) -%} {%- cycle '1', '2', '3' -%} {%- endfor -%} LIQUID assert_template_result("123", template) end def test_cycle_with_variables_inside_for_loop template = <<~LIQUID {%- assign a = 1 -%} {%- assign b = 2 -%} {%- assign c = 3 -%} {%- for i in (1..3) -%} {% cycle a, b, c %} {%- endfor -%} LIQUID assert_template_result("123", template) end def test_cycle_named_groups_string template = <<~LIQUID {%- for i in (1..3) -%} {%- cycle 'placeholder1': 1, 2, 3 -%} {%- cycle 'placeholder2': 1, 2, 3 -%} {%- endfor -%} LIQUID assert_template_result("112233", template) end def test_cycle_named_groups_vlookup template = <<~LIQUID {%- assign placeholder1 = 'placeholder1' -%} {%- assign placeholder2 = 'placeholder2' -%} {%- for i in (1..3) -%} {%- cycle placeholder1: 1, 2, 3 -%} {%- cycle placeholder2: 1, 2, 3 -%} {%- endfor -%} LIQUID assert_template_result("112233", template) end def test_unnamed_cycle_have_independent_counters_when_used_with_lookups template = <<~LIQUID {%- assign a = "1" -%} {%- for i in (1..3) -%} {%- cycle a, "2" -%} {%- cycle a, "2" -%} {%- endfor -%} LIQUID assert_template_result("112211", template) end def test_unnamed_cycle_dependent_counter_when_used_with_literal_values template = <<~LIQUID {%- cycle "1", "2" -%} {%- cycle "1", "2" -%} {%- cycle "1", "2" -%} LIQUID assert_template_result("121", template) end def test_optional_trailing_comma template = <<~LIQUID {%- cycle "1", "2", -%} {%- cycle "1", "2", -%} {%- cycle "1", "2", -%} {%- cycle "1", -%} LIQUID assert_template_result("1211", template) end def test_cycle_tag_without_arguments error = assert_raises(Liquid::SyntaxError) do Template.parse("{% cycle %}") end assert_match(/Syntax Error in 'cycle' - Valid syntax: cycle \[name :\] var/, error.message) end def test_cycle_tag_with_error_mode # QuotedFragment is more permissive than what Parser#expression allows. template1 = "{% assign 5 = 'b' %}{% cycle .5, .4 %}" template2 = "{% cycle .5: 'a', 'b' %}" with_error_modes(:lax, :strict) do assert_template_result("b", template1) assert_template_result("a", template2) end with_error_modes(:strict2) do error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } expected_error = /Liquid syntax error: \[:dot, "."\] is not a valid expression/ assert_match(expected_error, error1.message) assert_match(expected_error, error2.message) end end def test_cycle_with_trailing_elements assignments = "{% assign a = 'A' %}{% assign n = 'N' %}" template1 = "#{assignments}{% cycle 'a' 'b', 'c' %}" template2 = "#{assignments}{% cycle name: 'a' 'b', 'c' %}" template3 = "#{assignments}{% cycle name: 'a', 'b' 'c' %}" template4 = "#{assignments}{% cycle n e: 'a', 'b', 'c' %}" template5 = "#{assignments}{% cycle n e 'a', 'b', 'c' %}" with_error_modes(:lax, :strict) do assert_template_result("a", template1) assert_template_result("a", template2) assert_template_result("a", template3) assert_template_result("N", template4) assert_template_result("N", template5) end with_error_modes(:strict2) do error1 = assert_raises(Liquid::SyntaxError) { Template.parse(template1) } error2 = assert_raises(Liquid::SyntaxError) { Template.parse(template2) } error3 = assert_raises(Liquid::SyntaxError) { Template.parse(template3) } error4 = assert_raises(Liquid::SyntaxError) { Template.parse(template4) } error5 = assert_raises(Liquid::SyntaxError) { Template.parse(template5) } expected_error = /Expected end_of_string but found/ assert_match(expected_error, error1.message) assert_match(expected_error, error2.message) assert_match(expected_error, error3.message) assert_match(expected_error, error4.message) assert_match(expected_error, error5.message) end end def test_cycle_name_with_invalid_expression template = <<~LIQUID {% for i in (1..3) %} {% cycle foo=>bar: "a", "b" %} {% endfor %} LIQUID with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end def test_cycle_variable_with_invalid_expression template = <<~LIQUID {% for i in (1..3) %} {% cycle foo=>bar, "a", "b" %} {% endfor %} LIQUID with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end end ================================================ FILE: test/integration/tags/echo_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class EchoTest < Minitest::Test include Liquid def test_echo_outputs_its_input assert_template_result('BAR', <<~LIQUID, { 'variable-name' => 'bar' }) {%- echo variable-name | upcase -%} LIQUID end end ================================================ FILE: test/integration/tags/for_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ThingWithValue < Liquid::Drop def value 3 end end class ForTagTest < Minitest::Test include Liquid def test_for assert_template_result(' yo yo yo yo ', '{%for item in array%} yo {%endfor%}', { 'array' => [1, 2, 3, 4] }) assert_template_result('yoyo', '{%for item in array%}yo{%endfor%}', { 'array' => [1, 2] }) assert_template_result(' yo ', '{%for item in array%} yo {%endfor%}', { 'array' => [1] }) assert_template_result('', '{%for item in array%}{%endfor%}', { 'array' => [1, 2] }) expected = < [1, 2, 3] }) end def test_for_reversed assigns = { 'array' => [1, 2, 3] } assert_template_result('321', '{%for item in array reversed %}{{item}}{%endfor%}', assigns) end def test_for_with_range assert_template_result(' 1 2 3 ', '{%for item in (1..3) %} {{item}} {%endfor%}') assert_raises(Liquid::ArgumentError) do Template.parse('{% for i in (a..2) %}{% endfor %}').render!("a" => [1, 2]) end assert_template_result(' 0 1 2 3 ', '{% for item in (a..3) %} {{item}} {% endfor %}', { "a" => "invalid integer" }) end def test_for_with_variable_range assert_template_result(' 1 2 3 ', '{%for item in (1..foobar) %} {{item}} {%endfor%}', { "foobar" => 3 }) end def test_for_with_hash_value_range foobar = { "value" => 3 } assert_template_result(' 1 2 3 ', '{%for item in (1..foobar.value) %} {{item}} {%endfor%}', { "foobar" => foobar }) end def test_for_with_drop_value_range foobar = ThingWithValue.new assert_template_result(' 1 2 3 ', '{%for item in (1..foobar.value) %} {{item}} {%endfor%}', { "foobar" => foobar }) end def test_for_with_variable assert_template_result(' 1 2 3 ', '{%for item in array%} {{item}} {%endfor%}', { 'array' => [1, 2, 3] }) assert_template_result('123', '{%for item in array%}{{item}}{%endfor%}', { 'array' => [1, 2, 3] }) assert_template_result('123', '{% for item in array %}{{item}}{% endfor %}', { 'array' => [1, 2, 3] }) assert_template_result('abcd', '{%for item in array%}{{item}}{%endfor%}', { 'array' => ['a', 'b', 'c', 'd'] }) assert_template_result('a b c', '{%for item in array%}{{item}}{%endfor%}', { 'array' => ['a', ' ', 'b', ' ', 'c'] }) assert_template_result('abc', '{%for item in array%}{{item}}{%endfor%}', { 'array' => ['a', '', 'b', '', 'c'] }) end def test_for_helpers assigns = { 'array' => [1, 2, 3] } assert_template_result( ' 1/3 2/3 3/3 ', '{%for item in array%} {{forloop.index}}/{{forloop.length}} {%endfor%}', assigns, ) assert_template_result(' 1 2 3 ', '{%for item in array%} {{forloop.index}} {%endfor%}', assigns) assert_template_result(' 0 1 2 ', '{%for item in array%} {{forloop.index0}} {%endfor%}', assigns) assert_template_result(' 2 1 0 ', '{%for item in array%} {{forloop.rindex0}} {%endfor%}', assigns) assert_template_result(' 3 2 1 ', '{%for item in array%} {{forloop.rindex}} {%endfor%}', assigns) assert_template_result(' true false false ', '{%for item in array%} {{forloop.first}} {%endfor%}', assigns) assert_template_result(' false false true ', '{%for item in array%} {{forloop.last}} {%endfor%}', assigns) end def test_for_and_if assigns = { 'array' => [1, 2, 3] } assert_template_result( '+--', '{%for item in array%}{% if forloop.first %}+{% else %}-{% endif %}{%endfor%}', assigns, ) end def test_for_else assert_template_result('+++', '{%for item in array%}+{%else%}-{%endfor%}', { 'array' => [1, 2, 3] }) assert_template_result('-', '{%for item in array%}+{%else%}-{%endfor%}', { 'array' => [] }) assert_template_result('-', '{%for item in array%}+{%else%}-{%endfor%}', { 'array' => nil }) end def test_limiting assigns = { 'array' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } assert_template_result('12', '{%for i in array limit:2 %}{{ i }}{%endfor%}', assigns) assert_template_result('1234', '{%for i in array limit:4 %}{{ i }}{%endfor%}', assigns) assert_template_result('3456', '{%for i in array limit:4 offset:2 %}{{ i }}{%endfor%}', assigns) assert_template_result('3456', '{%for i in array limit: 4 offset: 2 %}{{ i }}{%endfor%}', assigns) assert_template_result('3456', '{%for i in array, limit: 4, offset: 2 %}{{ i }}{%endfor%}', assigns) end def test_limiting_with_invalid_limit assigns = { 'array' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } template = <<-MKUP {% for i in array limit: true offset: 1 %} {{ i }} {% endfor %} MKUP exception = assert_raises(Liquid::ArgumentError) do Template.parse(template).render!(assigns) end assert_equal("Liquid error: invalid integer", exception.message) end def test_limiting_with_invalid_offset assigns = { 'array' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } template = <<-MKUP {% for i in array limit: 1 offset: true %} {{ i }} {% endfor %} MKUP exception = assert_raises(Liquid::ArgumentError) do Template.parse(template).render!(assigns) end assert_equal("Liquid error: invalid integer", exception.message) end def test_dynamic_variable_limiting assigns = { 'array' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } assigns['limit'] = 2 assigns['offset'] = 2 assert_template_result('34', '{%for i in array limit: limit offset: offset %}{{ i }}{%endfor%}', assigns) end def test_nested_for assigns = { 'array' => [[1, 2], [3, 4], [5, 6]] } assert_template_result('123456', '{%for item in array%}{%for i in item%}{{ i }}{%endfor%}{%endfor%}', assigns) end def test_offset_only assigns = { 'array' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } assert_template_result('890', '{%for i in array offset:7 %}{{ i }}{%endfor%}', assigns) end def test_pause_resume assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } } markup = <<-MKUP {%for i in array.items limit: 3 %}{{i}}{%endfor%} next {%for i in array.items offset:continue limit: 3 %}{{i}}{%endfor%} next {%for i in array.items offset:continue limit: 3 %}{{i}}{%endfor%} MKUP expected = <<-XPCTD 123 next 456 next 789 XPCTD assert_template_result(expected, markup, assigns) end def test_pause_resume_limit assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } } markup = <<-MKUP {%for i in array.items limit:3 %}{{i}}{%endfor%} next {%for i in array.items offset:continue limit:3 %}{{i}}{%endfor%} next {%for i in array.items offset:continue limit:1 %}{{i}}{%endfor%} MKUP expected = <<-XPCTD 123 next 456 next 7 XPCTD assert_template_result(expected, markup, assigns) end def test_pause_resume_big_limit assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } } markup = <<-MKUP {%for i in array.items limit:3 %}{{i}}{%endfor%} next {%for i in array.items offset:continue limit:3 %}{{i}}{%endfor%} next {%for i in array.items offset:continue limit:1000 %}{{i}}{%endfor%} MKUP expected = <<-XPCTD 123 next 456 next 7890 XPCTD assert_template_result(expected, markup, assigns) end def test_pause_resume_big_offset assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 0] } } markup = '{%for i in array.items limit:3 %}{{i}}{%endfor%} next {%for i in array.items offset:continue limit:3 %}{{i}}{%endfor%} next {%for i in array.items offset:continue limit:3 offset:1000 %}{{i}}{%endfor%}' expected = '123 next 456 next ' assert_template_result(expected, markup, assigns) end def test_for_with_break assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] } } markup = '{% for i in array.items %}{% break %}{% endfor %}' expected = "" assert_template_result(expected, markup, assigns) markup = '{% for i in array.items %}{{ i }}{% break %}{% endfor %}' expected = "1" assert_template_result(expected, markup, assigns) markup = '{% for i in array.items %}{% break %}{{ i }}{% endfor %}' expected = "" assert_template_result(expected, markup, assigns) markup = '{% for i in array.items %}{{ i }}{% if i > 3 %}{% break %}{% endif %}{% endfor %}' expected = "1234" assert_template_result(expected, markup, assigns) # tests to ensure it only breaks out of the local for loop # and not all of them. assigns = { 'array' => [[1, 2], [3, 4], [5, 6]] } markup = '{% for item in array %}' \ '{% for i in item %}' \ '{% if i == 1 %}' \ '{% break %}' \ '{% endif %}' \ '{{ i }}' \ '{% endfor %}' \ '{% endfor %}' expected = '3456' assert_template_result(expected, markup, assigns) # test break does nothing when unreached assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5] } } markup = '{% for i in array.items %}{% if i == 9999 %}{% break %}{% endif %}{{ i }}{% endfor %}' expected = '12345' assert_template_result(expected, markup, assigns) end def test_for_with_break_after_nested_loop source = <<~LIQUID.chomp {% for i in (1..2) -%} {% for j in (1..2) -%} {{ i }}-{{ j }}, {%- endfor -%} {% break -%} {% endfor -%} after LIQUID assert_template_result("1-1,1-2,after", source) end def test_for_with_continue assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5] } } markup = '{% for i in array.items %}{% continue %}{% endfor %}' expected = "" assert_template_result(expected, markup, assigns) markup = '{% for i in array.items %}{{ i }}{% continue %}{% endfor %}' expected = "12345" assert_template_result(expected, markup, assigns) markup = '{% for i in array.items %}{% continue %}{{ i }}{% endfor %}' expected = "" assert_template_result(expected, markup, assigns) markup = '{% for i in array.items %}{% if i > 3 %}{% continue %}{% endif %}{{ i }}{% endfor %}' expected = "123" assert_template_result(expected, markup, assigns) markup = '{% for i in array.items %}{% if i == 3 %}{% continue %}{% else %}{{ i }}{% endif %}{% endfor %}' expected = "1245" assert_template_result(expected, markup, assigns) # tests to ensure it only continues the local for loop and not all of them. assigns = { 'array' => [[1, 2], [3, 4], [5, 6]] } markup = '{% for item in array %}' \ '{% for i in item %}' \ '{% if i == 1 %}' \ '{% continue %}' \ '{% endif %}' \ '{{ i }}' \ '{% endfor %}' \ '{% endfor %}' expected = '23456' assert_template_result(expected, markup, assigns) # test continue does nothing when unreached assigns = { 'array' => { 'items' => [1, 2, 3, 4, 5] } } markup = '{% for i in array.items %}{% if i == 9999 %}{% continue %}{% endif %}{{ i }}{% endfor %}' expected = '12345' assert_template_result(expected, markup, assigns) end def test_for_tag_string # ruby 1.8.7 "String".each => Enumerator with single "String" element. # ruby 1.9.3 no longer supports .each on String though we mimic # the functionality for backwards compatibility assert_template_result( 'test string', '{%for val in string%}{{val}}{%endfor%}', { 'string' => "test string" }, ) assert_template_result( 'test string', '{%for val in string limit:1%}{{val}}{%endfor%}', { 'string' => "test string" }, ) assert_template_result( 'val-string-1-1-0-1-0-true-true-test string', '{%for val in string%}' \ '{{forloop.name}}-' \ '{{forloop.index}}-' \ '{{forloop.length}}-' \ '{{forloop.index0}}-' \ '{{forloop.rindex}}-' \ '{{forloop.rindex0}}-' \ '{{forloop.first}}-' \ '{{forloop.last}}-' \ '{{val}}{%endfor%}', { 'string' => "test string" }, ) end def test_for_parentloop_references_parent_loop assert_template_result( '1.1 1.2 1.3 2.1 2.2 2.3 ', '{% for inner in outer %}{% for k in inner %}' \ '{{ forloop.parentloop.index }}.{{ forloop.index }} ' \ '{% endfor %}{% endfor %}', { 'outer' => [[1, 1, 1], [1, 1, 1]] }, ) end def test_for_parentloop_nil_when_not_present assert_template_result( '.1 .2 ', '{% for inner in outer %}' \ '{{ forloop.parentloop.index }}.{{ forloop.index }} ' \ '{% endfor %}', { 'outer' => [[1, 1, 1], [1, 1, 1]] }, ) end def test_inner_for_over_empty_input assert_template_result('oo', '{% for a in (1..2) %}o{% for b in empty %}{% endfor %}{% endfor %}') end def test_blank_string_not_iterable assert_template_result('', "{% for char in characters %}I WILL NOT BE OUTPUT{% endfor %}", { 'characters' => '' }) end def test_bad_variable_naming_in_for_loop assert_raises(Liquid::SyntaxError) do Liquid::Template.parse('{% for a/b in x %}{% endfor %}') end end def test_spacing_with_variable_naming_in_for_loop expected = '12345' template = '{% for item in items %}{{item}}{% endfor %}' assigns = { 'items' => [1, 2, 3, 4, 5] } assert_template_result(expected, template, assigns) end class LoaderDrop < Liquid::Drop attr_accessor :each_called, :load_slice_called def initialize(data) @data = data end def each @each_called = true @data.each { |el| yield el } end def load_slice(from, to) @load_slice_called = true @data[(from..to - 1)] end end def test_iterate_with_each_when_no_limit_applied loader = LoaderDrop.new([1, 2, 3, 4, 5]) assigns = { 'items' => loader } expected = '12345' template = '{% for item in items %}{{item}}{% endfor %}' assert_template_result(expected, template, assigns) assert(loader.each_called) assert(!loader.load_slice_called) end def test_iterate_with_load_slice_when_limit_applied loader = LoaderDrop.new([1, 2, 3, 4, 5]) assigns = { 'items' => loader } expected = '1' template = '{% for item in items limit:1 %}{{item}}{% endfor %}' assert_template_result(expected, template, assigns) assert(!loader.each_called) assert(loader.load_slice_called) end def test_iterate_with_load_slice_when_limit_and_offset_applied loader = LoaderDrop.new([1, 2, 3, 4, 5]) assigns = { 'items' => loader } expected = '34' template = '{% for item in items offset:2 limit:2 %}{{item}}{% endfor %}' assert_template_result(expected, template, assigns) assert(!loader.each_called) assert(loader.load_slice_called) end def test_iterate_with_load_slice_returns_same_results_as_without loader = LoaderDrop.new([1, 2, 3, 4, 5]) loader_assigns = { 'items' => loader } array_assigns = { 'items' => [1, 2, 3, 4, 5] } expected = '34' template = '{% for item in items offset:2 limit:2 %}{{item}}{% endfor %}' assert_template_result(expected, template, loader_assigns) assert_template_result(expected, template, array_assigns) end def test_for_cleans_up_registers context = Context.new(ErrorDrop.new) assert_raises(StandardError) do Liquid::Template.parse('{% for i in (1..2) %}{{ standard_error }}{% endfor %}').render!(context) end assert(context.registers[:for_stack].empty?) end end ================================================ FILE: test/integration/tags/if_else_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class IfElseTagTest < Minitest::Test include Liquid def test_if assert_template_result(' ', ' {% if false %} this text should not go into the output {% endif %} ') assert_template_result( ' this text should go into the output ', ' {% if true %} this text should go into the output {% endif %} ', ) assert_template_result(' you rock ?', '{% if false %} you suck {% endif %} {% if true %} you rock {% endif %}?') end def test_literal_comparisons assert_template_result(' NO ', '{% assign v = false %}{% if v %} YES {% else %} NO {% endif %}') assert_template_result(' YES ', '{% assign v = nil %}{% if v == nil %} YES {% else %} NO {% endif %}') end def test_if_else assert_template_result(' YES ', '{% if false %} NO {% else %} YES {% endif %}') assert_template_result(' YES ', '{% if true %} YES {% else %} NO {% endif %}') assert_template_result(' YES ', '{% if "foo" %} YES {% else %} NO {% endif %}') end def test_if_boolean assert_template_result(' YES ', '{% if var %} YES {% endif %}', { 'var' => true }) end def test_if_or assert_template_result(' YES ', '{% if a or b %} YES {% endif %}', { 'a' => true, 'b' => true }) assert_template_result(' YES ', '{% if a or b %} YES {% endif %}', { 'a' => true, 'b' => false }) assert_template_result(' YES ', '{% if a or b %} YES {% endif %}', { 'a' => false, 'b' => true }) assert_template_result('', '{% if a or b %} YES {% endif %}', { 'a' => false, 'b' => false }) assert_template_result(' YES ', '{% if a or b or c %} YES {% endif %}', { 'a' => false, 'b' => false, 'c' => true }) assert_template_result('', '{% if a or b or c %} YES {% endif %}', { 'a' => false, 'b' => false, 'c' => false }) end def test_if_or_with_operators assert_template_result(' YES ', '{% if a == true or b == true %} YES {% endif %}', { 'a' => true, 'b' => true }) assert_template_result(' YES ', '{% if a == true or b == false %} YES {% endif %}', { 'a' => true, 'b' => true }) assert_template_result('', '{% if a == false or b == false %} YES {% endif %}', { 'a' => true, 'b' => true }) end def test_comparison_of_strings_containing_and_or_or awful_markup = "a == 'and' and b == 'or' and c == 'foo and bar' and d == 'bar or baz' and e == 'foo' and foo and bar" assigns = { 'a' => 'and', 'b' => 'or', 'c' => 'foo and bar', 'd' => 'bar or baz', 'e' => 'foo', 'foo' => true, 'bar' => true } assert_template_result(' YES ', "{% if #{awful_markup} %} YES {% endif %}", assigns) end def test_comparison_of_expressions_starting_with_and_or_or assigns = { 'order' => { 'items_count' => 0 }, 'android' => { 'name' => 'Roy' } } assert_template_result( "YES", "{% if android.name == 'Roy' %}YES{% endif %}", assigns, ) assert_template_result( "YES", "{% if order.items_count == 0 %}YES{% endif %}", assigns, ) end def test_if_and assert_template_result(' YES ', '{% if true and true %} YES {% endif %}') assert_template_result('', '{% if false and true %} YES {% endif %}') assert_template_result('', '{% if true and false %} YES {% endif %}') end def test_hash_miss_generates_false assert_template_result('', '{% if foo.bar %} NO {% endif %}', { 'foo' => {} }) end def test_if_from_variable assert_template_result('', '{% if var %} NO {% endif %}', { 'var' => false }) assert_template_result('', '{% if var %} NO {% endif %}', { 'var' => nil }) assert_template_result('', '{% if foo.bar %} NO {% endif %}', { 'foo' => { 'bar' => false } }) assert_template_result('', '{% if foo.bar %} NO {% endif %}', { 'foo' => {} }) assert_template_result('', '{% if foo.bar %} NO {% endif %}', { 'foo' => nil }) assert_template_result('', '{% if foo.bar %} NO {% endif %}', { 'foo' => true }) assert_template_result(' YES ', '{% if var %} YES {% endif %}', { 'var' => "text" }) assert_template_result(' YES ', '{% if var %} YES {% endif %}', { 'var' => true }) assert_template_result(' YES ', '{% if var %} YES {% endif %}', { 'var' => 1 }) assert_template_result(' YES ', '{% if var %} YES {% endif %}', { 'var' => {} }) assert_template_result(' YES ', '{% if var %} YES {% endif %}', { 'var' => [] }) assert_template_result(' YES ', '{% if "foo" %} YES {% endif %}') assert_template_result(' YES ', '{% if foo.bar %} YES {% endif %}', { 'foo' => { 'bar' => true } }) assert_template_result(' YES ', '{% if foo.bar %} YES {% endif %}', { 'foo' => { 'bar' => "text" } }) assert_template_result(' YES ', '{% if foo.bar %} YES {% endif %}', { 'foo' => { 'bar' => 1 } }) assert_template_result(' YES ', '{% if foo.bar %} YES {% endif %}', { 'foo' => { 'bar' => {} } }) assert_template_result(' YES ', '{% if foo.bar %} YES {% endif %}', { 'foo' => { 'bar' => [] } }) assert_template_result(' YES ', '{% if var %} NO {% else %} YES {% endif %}', { 'var' => false }) assert_template_result(' YES ', '{% if var %} NO {% else %} YES {% endif %}', { 'var' => nil }) assert_template_result(' YES ', '{% if var %} YES {% else %} NO {% endif %}', { 'var' => true }) assert_template_result(' YES ', '{% if "foo" %} YES {% else %} NO {% endif %}', { 'var' => "text" }) assert_template_result(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', { 'foo' => { 'bar' => false } }) assert_template_result(' YES ', '{% if foo.bar %} YES {% else %} NO {% endif %}', { 'foo' => { 'bar' => true } }) assert_template_result(' YES ', '{% if foo.bar %} YES {% else %} NO {% endif %}', { 'foo' => { 'bar' => "text" } }) assert_template_result(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', { 'foo' => { 'notbar' => true } }) assert_template_result(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', { 'foo' => {} }) assert_template_result(' YES ', '{% if foo.bar %} NO {% else %} YES {% endif %}', { 'notfoo' => { 'bar' => true } }) end def test_nested_if assert_template_result('', '{% if false %}{% if false %} NO {% endif %}{% endif %}') assert_template_result('', '{% if false %}{% if true %} NO {% endif %}{% endif %}') assert_template_result('', '{% if true %}{% if false %} NO {% endif %}{% endif %}') assert_template_result(' YES ', '{% if true %}{% if true %} YES {% endif %}{% endif %}') assert_template_result(' YES ', '{% if true %}{% if true %} YES {% else %} NO {% endif %}{% else %} NO {% endif %}') assert_template_result(' YES ', '{% if true %}{% if false %} NO {% else %} YES {% endif %}{% else %} NO {% endif %}') assert_template_result(' YES ', '{% if false %}{% if true %} NO {% else %} NONO {% endif %}{% else %} YES {% endif %}') end def test_comparisons_on_null assert_template_result('', '{% if null < 10 %} NO {% endif %}') assert_template_result('', '{% if null <= 10 %} NO {% endif %}') assert_template_result('', '{% if null >= 10 %} NO {% endif %}') assert_template_result('', '{% if null > 10 %} NO {% endif %}') assert_template_result('', '{% if 10 < null %} NO {% endif %}') assert_template_result('', '{% if 10 <= null %} NO {% endif %}') assert_template_result('', '{% if 10 >= null %} NO {% endif %}') assert_template_result('', '{% if 10 > null %} NO {% endif %}') end def test_else_if assert_template_result('0', '{% if 0 == 0 %}0{% elsif 1 == 1%}1{% else %}2{% endif %}') assert_template_result('1', '{% if 0 != 0 %}0{% elsif 1 == 1%}1{% else %}2{% endif %}') assert_template_result('2', '{% if 0 != 0 %}0{% elsif 1 != 1%}1{% else %}2{% endif %}') assert_template_result('elsif', '{% if false %}if{% elsif true %}elsif{% endif %}') end def test_syntax_error_no_variable assert_raises(SyntaxError) { assert_template_result('', '{% if jerry == 1 %}') } end def test_syntax_error_no_expression assert_raises(SyntaxError) { assert_template_result('', '{% if %}') } end def test_if_with_custom_condition original_op = Condition.operators['contains'] Condition.operators['contains'] = :[] assert_template_result('yes', %({% if 'bob' contains 'o' %}yes{% endif %})) assert_template_result('no', %({% if 'bob' contains 'f' %}yes{% else %}no{% endif %})) ensure Condition.operators['contains'] = original_op end def test_operators_are_ignored_unless_isolated original_op = Condition.operators['contains'] Condition.operators['contains'] = :[] assert_template_result( 'yes', %({% if 'gnomeslab-and-or-liquid' contains 'gnomeslab-and-or-liquid' %}yes{% endif %}), ) ensure Condition.operators['contains'] = original_op end def test_operators_are_whitelisted assert_raises(SyntaxError) do assert_template_result('', %({% if 1 or throw or or 1 %}yes{% endif %})) end end def test_multiple_conditions tpl = "{% if a or b and c %}true{% else %}false{% endif %}" tests = { [true, true, true] => true, [true, true, false] => true, [true, false, true] => true, [true, false, false] => true, [false, true, true] => true, [false, true, false] => false, [false, false, true] => false, [false, false, false] => false, } tests.each do |vals, expected| a, b, c = vals assigns = { 'a' => a, 'b' => b, 'c' => c } assert_template_result(expected.to_s, tpl, assigns, message: assigns.to_s) end end end ================================================ FILE: test/integration/tags/include_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TestFileSystem PARTIALS = { "nested_template" => "{% include 'header' %} {% include 'body' %} {% include 'footer' %}", "body" => "body {% include 'body_detail' %}", } def read_template_file(template_path) PARTIALS[template_path] || template_path end end class OtherFileSystem def read_template_file(_template_path) 'from OtherFileSystem' end end class CountingFileSystem attr_reader :count def read_template_file(_template_path) @count ||= 0 @count += 1 'from CountingFileSystem' end end class CustomInclude < Liquid::Tag Syntax = /(#{Liquid::QuotedFragment}+)(\s+(?:with|for)\s+(#{Liquid::QuotedFragment}+))?/o def initialize(tag_name, markup, tokens) markup =~ Syntax @template_name = Regexp.last_match(1) super end def parse(tokens) end def render_to_output_buffer(_context, output) output << @template_name[1..-2] output end end class IncludeTagTest < Minitest::Test include Liquid def test_include_tag_looks_for_file_system_in_registers_first assert_equal( 'from OtherFileSystem', Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: OtherFileSystem.new }), ) end def test_include_tag_with assert_template_result( "Product: Draft 151cm ", "{% include 'product' with products[0] %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { "product" => "Product: {{ product.title }} " }, ) end def test_include_tag_with_alias assert_template_result( "Product: Draft 151cm ", "{% include 'product_alias' with products[0] as product %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { "product_alias" => "Product: {{ product.title }} " }, ) end def test_include_tag_for_alias assert_template_result( "Product: Draft 151cm Product: Element 155cm ", "{% include 'product_alias' for products as product %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { "product_alias" => "Product: {{ product.title }} " }, ) end def test_include_tag_with_default_name assert_template_result( "Product: Draft 151cm ", "{% include 'product' %}", { "product" => { 'title' => 'Draft 151cm' } }, partials: { "product" => "Product: {{ product.title }} " }, ) end def test_include_tag_for assert_template_result( "Product: Draft 151cm Product: Element 155cm ", "{% include 'product' for products %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { "product" => "Product: {{ product.title }} " }, ) end def test_include_tag_with_local_variables assert_template_result( "Locale: test123 ", "{% include 'locale_variables' echo1: 'test123' %}", partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }, ) end def test_include_tag_with_multiple_local_variables assert_template_result( "Locale: test123 test321", "{% include 'locale_variables' echo1: 'test123', echo2: 'test321' %}", partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }, ) end def test_include_tag_with_multiple_local_variables_from_context assert_template_result( "Locale: test123 test321", "{% include 'locale_variables' echo1: echo1, echo2: more_echos.echo2 %}", { 'echo1' => 'test123', 'more_echos' => { "echo2" => 'test321' } }, partials: { "locale_variables" => "Locale: {{echo1}} {{echo2}}" }, ) end def test_included_templates_assigns_variables assert_template_result( "bar", "{% include 'assignments' %}{{ foo }}", partials: { 'assignments' => "{% assign foo = 'bar' %}" }, ) end def test_nested_include_tag partials = { "body" => "body {% include 'body_detail' %}", "body_detail" => "body_detail" } assert_template_result("body body_detail", "{% include 'body' %}", partials: partials) partials = partials.merge({ "nested_template" => "{% include 'header' %} {% include 'body' %} {% include 'footer' %}", "header" => "header", "footer" => "footer", }) assert_template_result("header body body_detail footer", "{% include 'nested_template' %}", partials: partials) end def test_nested_include_with_variable partials = { "nested_product_template" => "Product: {{ nested_product_template.title }} {%include 'details'%} ", "details" => "details", } assert_template_result( "Product: Draft 151cm details ", "{% include 'nested_product_template' with product %}", { "product" => { "title" => 'Draft 151cm' } }, partials: partials, ) assert_template_result( "Product: Draft 151cm details Product: Element 155cm details ", "{% include 'nested_product_template' for products %}", { "products" => [{ "title" => 'Draft 151cm' }, { "title" => 'Element 155cm' }] }, partials: partials, ) end def test_recursively_included_template_does_not_produce_endless_loop infinite_file_system = Class.new do def read_template_file(_template_path) "-{% include 'loop' %}" end end env = Liquid::Environment.build(file_system: infinite_file_system.new) assert_raises(Liquid::StackLevelError) do Template.parse("{% include 'loop' %}", environment: env).render! end end def test_dynamically_choosen_template assert_template_result( "Test123", "{% include template %}", { "template" => 'Test123' }, partials: { "Test123" => "Test123" }, ) assert_template_result( "Test321", "{% include template %}", { "template" => 'Test321' }, partials: { "Test321" => "Test321" }, ) assert_template_result( "Product: Draft 151cm ", "{% include template for product %}", { "template" => 'product', 'product' => { 'title' => 'Draft 151cm' } }, partials: { "product" => "Product: {{ product.title }} " }, ) end def test_strict2_parsing_errors with_error_modes(:lax, :strict) do assert_template_result( 'hello value1 value2', '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }, ) end with_error_modes(:strict2) do assert_syntax_error( '{% include "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', ) assert_syntax_error( '{% include "snippet" | filter %}', ) end end def test_optional_commas partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' } assert_template_result('hello value1 value2', '{% include "snippet", arg1: "value1", arg2: "value2" %}', partials: partials) assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1", arg2: "value2" %}', partials: partials) assert_template_result('hello value1 value2', '{% include "snippet" arg1: "value1" arg2: "value2" %}', partials: partials) end def test_include_tag_caches_second_read_of_same_partial file_system = CountingFileSystem.new environment = Liquid::Environment.build(file_system: file_system) assert_equal( 'from CountingFileSystemfrom CountingFileSystem', Template.parse("{% include 'pick_a_source' %}{% include 'pick_a_source' %}", environment: environment).render!({}, registers: { file_system: file_system }), ) assert_equal(1, file_system.count) end def test_include_tag_doesnt_cache_partials_across_renders file_system = CountingFileSystem.new assert_equal( 'from CountingFileSystem', Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }), ) assert_equal(1, file_system.count) assert_equal( 'from CountingFileSystem', Template.parse("{% include 'pick_a_source' %}").render!({}, registers: { file_system: file_system }), ) assert_equal(2, file_system.count) end def test_include_tag_within_if_statement assert_template_result( "foo_if_true", "{% if true %}{% include 'foo_if_true' %}{% endif %}", partials: { "foo_if_true" => "foo_if_true" }, ) end def test_custom_include_tag original_tag = Liquid::Template.tags['include'] Liquid::Template.tags['include'] = CustomInclude begin assert_equal( "custom_foo", Template.parse("{% include 'custom_foo' %}").render!, ) ensure Liquid::Template.tags['include'] = original_tag end end def test_custom_include_tag_within_if_statement original_tag = Liquid::Template.tags['include'] Liquid::Template.tags['include'] = CustomInclude begin assert_equal( "custom_foo_if_true", Template.parse("{% if true %}{% include 'custom_foo_if_true' %}{% endif %}").render!, ) ensure Liquid::Template.tags['include'] = original_tag end end def test_does_not_add_error_in_strict_mode_for_missing_variable env = Liquid::Environment.build(file_system: TestFileSystem.new) a = Liquid::Template.parse(' {% include "nested_template" %}', environment: env) a.render! assert_empty(a.errors) end def test_passing_options_to_included_templates env = Liquid::Environment.build(file_system: TestFileSystem.new) assert_raises(Liquid::SyntaxError) do Template.parse("{% include template %}", error_mode: :strict, environment: env).render!("template" => '{{ "X" || downcase }}') end with_error_modes(:lax) do assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: true, environment: env).render!("template" => '{{ "X" || downcase }}')) end assert_raises(Liquid::SyntaxError) do Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:locale], environment: env).render!("template" => '{{ "X" || downcase }}') end with_error_modes(:lax) do assert_equal('x', Template.parse("{% include template %}", error_mode: :strict, include_options_blacklist: [:error_mode], environment: env).render!("template" => '{{ "X" || downcase }}')) end end def test_render_raise_argument_error_when_template_is_undefined assert_template_result( "Liquid error (line 1): Argument error in tag 'include' - Illegal template name", "{% include undefined_variable %}", render_errors: true, ) assert_template_result( "Liquid error (line 1): Argument error in tag 'include' - Illegal template name", "{% include nil %}", render_errors: true, ) end def test_render_raise_argument_error_when_template_is_not_a_string assert_template_result( "Liquid error (line 1): Argument error in tag 'include' - Illegal template name", "{% include 123 %}", render_errors: true, ) end def test_including_via_variable_value assert_template_result( "from TestFileSystem", "{% assign page = 'pick_a_source' %}{% include page %}", partials: { "pick_a_source" => "from TestFileSystem" }, ) partials = { "product" => "Product: {{ product.title }} " } assert_template_result( "Product: Draft 151cm ", "{% assign page = 'product' %}{% include page %}", { "product" => { 'title' => 'Draft 151cm' } }, partials: partials, ) assert_template_result( "Product: Draft 151cm ", "{% assign page = 'product' %}{% include page for foo %}", { "foo" => { 'title' => 'Draft 151cm' } }, partials: partials, ) end def test_including_with_strict_variables env = Liquid::Environment.build( file_system: StubFileSystem.new('simple' => 'simple'), ) template = Liquid::Template.parse("{% include 'simple' %}", error_mode: :warn, environment: env) template.render(nil, strict_variables: true) assert_equal([], template.errors) end def test_break_through_include assert_template_result("1", "{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}") assert_template_result( "1", "{% for i in (1..3) %}{{ i }}{% include 'break' %}{{ i }}{% endfor %}", partials: { 'break' => "{% break %}" }, ) end def test_render_tag_renders_error_with_template_name assert_template_result( 'Liquid error (foo line 1): standard error', "{% include 'foo' with errors %}", { 'errors' => ErrorDrop.new }, partials: { 'foo' => '{{ foo.standard_error }}' }, render_errors: true, ) end def test_render_tag_renders_error_with_template_name_from_template_factory assert_template_result( 'Liquid error (some/path/foo line 1): standard error', "{% include 'foo' with errors %}", { 'errors' => ErrorDrop.new }, partials: { 'foo' => '{{ foo.standard_error }}' }, template_factory: StubTemplateFactory.new, render_errors: true, ) end def test_include_template_with_invalid_expression template = "{% include foo=>bar %}" with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end def test_include_with_invalid_expression template = '{% include "snippet" with foo=>bar %}' with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end def test_include_attribute_with_invalid_expression template = '{% include "snippet", key: foo=>bar %}' with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end end # IncludeTagTest ================================================ FILE: test/integration/tags/increment_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class IncrementTagTest < Minitest::Test include Liquid def test_inc assert_template_result('0 1', '{%increment port %} {{ port }}') assert_template_result(' 0 1 2', '{{port}} {%increment port %} {%increment port%} {{port}}') assert_template_result( '0 0 1 2 1', '{%increment port %} {%increment starboard%} ' \ '{%increment port %} {%increment port%} ' \ '{%increment starboard %}', ) end def test_dec assert_template_result('-1 -1', '{%decrement port %} {{ port }}', { 'port' => 10 }) assert_template_result(' -1 -2 -2', '{{port}} {%decrement port %} {%decrement port%} {{port}}') assert_template_result( '0 1 2 0 3 1 1 3', '{%increment starboard %} {%increment starboard%} {%increment starboard%} ' \ '{%increment port %} {%increment starboard%} ' \ '{%increment port %} {%decrement port%} ' \ '{%decrement starboard %}', ) end end ================================================ FILE: test/integration/tags/inline_comment_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class InlineCommentTest < Minitest::Test include Liquid def test_inline_comment_returns_nothing assert_template_result('', '{%- # this is an inline comment -%}') assert_template_result('', '{%-# this is an inline comment -%}') assert_template_result('', '{% # this is an inline comment %}') assert_template_result('', '{%# this is an inline comment %}') end def test_inline_comment_does_not_require_a_space_after_the_pound_sign assert_template_result('', '{%#this is an inline comment%}') end def test_liquid_inline_comment_returns_nothing assert_template_result('Hey there, how are you doing today?', <<~LIQUID) {%- liquid # This is how you'd write a block comment in a liquid tag. # It looks a lot like what you'd have in ruby. # You can use it as inline documentation in your # liquid blocks to explain why you're doing something. echo "Hey there, " # It won't affect the output. echo "how are you doing today?" -%} LIQUID end def test_inline_comment_can_be_written_on_multiple_lines assert_template_result('', <<~LIQUID) {%- # That kind of block comment is also allowed. # It would only be a stylistic difference. # Much like JavaScript's /* */ comments and their # leading * on new lines. -%} LIQUID end def test_inline_comment_multiple_pound_signs assert_template_result('', <<~LIQUID) {%- liquid ###################################### # We support comments like this too. # ###################################### -%} LIQUID end def test_inline_comments_require_the_pound_sign_on_every_new_line assert_match_syntax_error("Each line of comments must be prefixed by the '#' character", <<~LIQUID) {%- # some comment echo 'hello world' -%} LIQUID end def test_inline_comment_does_not_support_nested_tags assert_template_result(' -%}', "{%- # {% echo 'hello world' %} -%}") end end ================================================ FILE: test/integration/tags/liquid_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class LiquidTagTest < Minitest::Test include Liquid def test_liquid_tag assert_template_result('1 2 3', <<~LIQUID, { 'array' => [1, 2, 3] }) {%- liquid echo array | join: " " -%} LIQUID assert_template_result('1 2 3', <<~LIQUID, { 'array' => [1, 2, 3] }) {%- liquid for value in array echo value unless forloop.last echo " " endunless endfor -%} LIQUID assert_template_result('4 8 12 6', <<~LIQUID, { 'array' => [1, 2, 3] }) {%- liquid for value in array assign double_value = value | times: 2 echo double_value | times: 2 unless forloop.last echo " " endunless endfor echo " " echo double_value -%} LIQUID assert_template_result('abc', <<~LIQUID) {%- liquid echo "a" -%} b {%- liquid echo "c" -%} LIQUID end def test_liquid_tag_errors assert_match_syntax_error("syntax error (line 1): Unknown tag 'error'", <<~LIQUID) {%- liquid error no such tag -%} LIQUID assert_match_syntax_error("syntax error (line 7): Unknown tag 'error'", <<~LIQUID) {{ test }} {%- liquid for value in array error no such tag endfor -%} LIQUID assert_match_syntax_error("syntax error (line 2): Unknown tag '!!! the guards are vigilant'", <<~LIQUID) {%- liquid !!! the guards are vigilant -%} LIQUID assert_match_syntax_error("syntax error (line 4): 'for' tag was never closed", <<~LIQUID) {%- liquid for value in array echo 'forgot to close the for tag' -%} LIQUID end def test_line_number_is_correct_after_a_blank_token assert_match_syntax_error("syntax error (line 3): Unknown tag 'error'", "{% liquid echo ''\n\n error %}") assert_match_syntax_error("syntax error (line 3): Unknown tag 'error'", "{% liquid echo ''\n \n error %}") end def test_nested_liquid_tag assert_template_result('good', <<~LIQUID) {%- if true %} {%- liquid echo "good" %} {%- endif -%} LIQUID end def test_cannot_open_blocks_living_past_a_liquid_tag assert_match_syntax_error("syntax error (line 3): 'if' tag was never closed", <<~LIQUID) {%- liquid if true -%} {%- endif -%} LIQUID end def test_cannot_close_blocks_created_before_a_liquid_tag assert_match_syntax_error("syntax error (line 3): 'endif' is not a valid delimiter for liquid tags. use %}", <<~LIQUID) {%- if true -%} 42 {%- liquid endif -%} LIQUID end def test_liquid_tag_in_raw assert_template_result("{% liquid echo 'test' %}\n", <<~LIQUID) {% raw %}{% liquid echo 'test' %}{% endraw %} LIQUID end def test_nested_liquid_tags assert_template_result('good', <<~LIQUID) {%- liquid liquid if true echo "good" endif -%} LIQUID end def test_nested_liquid_tags_on_same_line assert_template_result('good', <<~LIQUID) {%- liquid liquid liquid echo "good" -%} LIQUID end def test_nested_liquid_liquid_is_not_skipped_if_used_in_non_tag_position assert_template_result('liquid', <<~LIQUID, { 'liquid' => 'liquid' }) {%- liquid liquid liquid echo liquid -%} LIQUID end def test_next_liquid_with_unclosed_if_tag assert_match_syntax_error("Liquid syntax error (line 2): 'if' tag was never closed", <<~LIQUID) {%- liquid liquid if true echo "good" endif -%} LIQUID end end ================================================ FILE: test/integration/tags/raw_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class RawTagTest < Minitest::Test include Liquid def test_tag_in_raw assert_template_result( '{% comment %} test {% endcomment %}', '{% raw %}{% comment %} test {% endcomment %}{% endraw %}', ) end def test_output_in_raw assert_template_result('>{{ test }}<', '> {%- raw -%}{{ test }}{%- endraw -%} <') assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw %} <") assert_template_result("> inner <", "> {%- raw -%} inner {%- endraw -%} <") assert_template_result("{Hello}", "{% raw %}{{% endraw %}Hello{% raw %}}{% endraw %}") end def test_open_tag_in_raw assert_template_result(' Foobar {% invalid ', '{% raw %} Foobar {% invalid {% endraw %}') assert_template_result(' Foobar invalid %} ', '{% raw %} Foobar invalid %} {% endraw %}') assert_template_result(' Foobar {{ invalid ', '{% raw %} Foobar {{ invalid {% endraw %}') assert_template_result(' Foobar invalid }} ', '{% raw %} Foobar invalid }} {% endraw %}') assert_template_result(' Foobar {% invalid {% {% endraw ', '{% raw %} Foobar {% invalid {% {% endraw {% endraw %}') assert_template_result(' Foobar {% {% {% ', '{% raw %} Foobar {% {% {% {% endraw %}') assert_template_result(' test {% raw %} {% endraw %}', '{% raw %} test {% raw %} {% {% endraw %}endraw %}') assert_template_result(' Foobar {{ invalid 1', '{% raw %} Foobar {{ invalid {% endraw %}{{ 1 }}') assert_template_result(' Foobar {% foo {% bar %}', '{% raw %} Foobar {% foo {% bar %}{% endraw %}') end def test_invalid_raw assert_match_syntax_error(/tag was never closed/, '{% raw %} foo') assert_match_syntax_error(/Valid syntax/, '{% raw } foo {% endraw %}') assert_match_syntax_error(/Valid syntax/, '{% raw } foo %}{% endraw %}') end end ================================================ FILE: test/integration/tags/render_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class RenderTagTest < Minitest::Test include Liquid def test_render_with_no_arguments assert_template_result( 'rendered content', '{% render "source" %}', partials: { 'source' => 'rendered content' }, ) end def test_render_tag_looks_for_file_system_in_registers_first assert_template_result( 'from register file system', '{% render "pick_a_source" %}', partials: { 'pick_a_source' => 'from register file system' }, ) end def test_render_passes_named_arguments_into_inner_scope assert_template_result( 'My Product', '{% render "product", inner_product: outer_product %}', { 'outer_product' => { 'title' => 'My Product' } }, partials: { 'product' => '{{ inner_product.title }}' }, ) end def test_render_accepts_literals_as_arguments assert_template_result( '123', '{% render "snippet", price: 123 %}', partials: { 'snippet' => '{{ price }}' }, ) end def test_render_accepts_multiple_named_arguments assert_template_result( '1 2', '{% render "snippet", one: 1, two: 2 %}', partials: { 'snippet' => '{{ one }} {{ two }}' }, ) end def test_render_does_not_inherit_parent_scope_variables assert_template_result( '', '{% assign outer_variable = "should not be visible" %}{% render "snippet" %}', partials: { 'snippet' => '{{ outer_variable }}' }, ) end def test_render_does_not_inherit_variable_with_same_name_as_snippet assert_template_result( '', "{% assign snippet = 'should not be visible' %}{% render 'snippet' %}", partials: { 'snippet' => '{{ snippet }}' }, ) end def test_render_does_not_mutate_parent_scope assert_template_result( '', "{% render 'snippet' %}{{ inner }}", partials: { 'snippet' => '{% assign inner = 1 %}' }, ) end def test_nested_render_tag assert_template_result( 'one two', "{% render 'one' %}", partials: { 'one' => "one {% render 'two' %}", 'two' => 'two', }, ) end def test_recursively_rendered_template_does_not_produce_endless_loop env = Liquid::Environment.build( file_system: StubFileSystem.new('loop' => '{% render "loop" %}'), ) assert_raises(Liquid::StackLevelError) do Template.parse('{% render "loop" %}', environment: env).render! end end def test_sub_contexts_count_towards_the_same_recursion_limit env = Liquid::Environment.build( file_system: StubFileSystem.new('loop_render' => '{% render "loop_render" %}'), ) assert_raises(Liquid::StackLevelError) do Template.parse('{% render "loop_render" %}', environment: env).render! end end def test_dynamically_choosen_templates_are_not_allowed assert_syntax_error("{% assign name = 'snippet' %}{% render name %}") end def test_strict2_parsing_errors with_error_modes(:lax, :strict) do assert_template_result( 'hello value1 value2', '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', partials: { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' }, ) end with_error_modes(:strict2) do assert_syntax_error( '{% render "snippet" !!! arg1: "value1" ~~~ arg2: "value2" %}', ) assert_syntax_error( '{% render "snippet" | filter %}', ) end end def test_optional_commas partials = { 'snippet' => 'hello {{ arg1 }} {{ arg2 }}' } assert_template_result('hello value1 value2', '{% render "snippet", arg1: "value1", arg2: "value2" %}', partials: partials) assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1", arg2: "value2" %}', partials: partials) assert_template_result('hello value1 value2', '{% render "snippet" arg1: "value1" arg2: "value2" %}', partials: partials) end def test_render_tag_caches_second_read_of_same_partial file_system = StubFileSystem.new('snippet' => 'echo') assert_equal( 'echoecho', Template.parse('{% render "snippet" %}{% render "snippet" %}') .render!({}, registers: { file_system: file_system }), ) assert_equal(1, file_system.file_read_count) end def test_render_tag_doesnt_cache_partials_across_renders file_system = StubFileSystem.new('snippet' => 'my message') assert_equal( 'my message', Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system }), ) assert_equal(1, file_system.file_read_count) assert_equal( 'my message', Template.parse('{% include "snippet" %}').render!({}, registers: { file_system: file_system }), ) assert_equal(2, file_system.file_read_count) end def test_render_tag_within_if_statement assert_template_result( 'my message', '{% if true %}{% render "snippet" %}{% endif %}', partials: { 'snippet' => 'my message' }, ) end def test_break_through_render options = { partials: { 'break' => '{% break %}' } } assert_template_result('1', '{% for i in (1..3) %}{{ i }}{% break %}{{ i }}{% endfor %}', **options) assert_template_result('112233', '{% for i in (1..3) %}{{ i }}{% render "break" %}{{ i }}{% endfor %}', **options) end def test_increment_is_isolated_between_renders assert_template_result( '010', '{% increment %}{% increment %}{% render "incr" %}', partials: { 'incr' => '{% increment %}' }, ) end def test_decrement_is_isolated_between_renders assert_template_result( '-1-2-1', '{% decrement %}{% decrement %}{% render "decr" %}', partials: { 'decr' => '{% decrement %}' }, ) end def test_includes_will_not_render_inside_render_tag assert_template_result( 'Liquid error (test_include line 1): include usage is not allowed in this context', '{% render "test_include" %}', render_errors: true, partials: { 'foo' => 'bar', 'test_include' => '{% include "foo" %}', }, ) end def test_includes_will_not_render_inside_nested_sibling_tags assert_template_result( "Liquid error (test_include line 1): include usage is not allowed in this context" \ "Liquid error (nested_render_with_sibling_include line 1): include usage is not allowed in this context", '{% render "nested_render_with_sibling_include" %}', partials: { 'foo' => 'bar', 'nested_render_with_sibling_include' => '{% render "test_include" %}{% include "foo" %}', 'test_include' => '{% include "foo" %}', }, render_errors: true, ) end def test_render_tag_with assert_template_result( "Product: Draft 151cm ", "{% render 'product' with products[0] %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} ", 'product_alias' => "Product: {{ product.title }} ", }, ) end def test_render_tag_with_alias assert_template_result( "Product: Draft 151cm ", "{% render 'product_alias' with products[0] as product %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} ", 'product_alias' => "Product: {{ product.title }} ", }, ) end def test_render_tag_for_alias assert_template_result( "Product: Draft 151cm Product: Element 155cm ", "{% render 'product_alias' for products as product %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} ", 'product_alias' => "Product: {{ product.title }} ", }, ) end def test_render_tag_for assert_template_result( "Product: Draft 151cm Product: Element 155cm ", "{% render 'product' for products %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} ", 'product_alias' => "Product: {{ product.title }} ", }, ) end def test_render_tag_forloop assert_template_result( "Product: Draft 151cm first index:1 Product: Element 155cm last index:2 ", "{% render 'product' for products %}", { "products" => [{ 'title' => 'Draft 151cm' }, { 'title' => 'Element 155cm' }] }, partials: { 'product' => "Product: {{ product.title }} {% if forloop.first %}first{% endif %} {% if forloop.last %}last{% endif %} index:{{ forloop.index }} ", }, ) end def test_render_tag_for_drop assert_template_result( "123", "{% render 'loop' for loop as value %}", { "loop" => TestEnumerable.new }, partials: { 'loop' => "{{ value.foo }}", }, ) end def test_render_tag_with_drop assert_template_result( "TestEnumerable", "{% render 'loop' with loop as value %}", { "loop" => TestEnumerable.new }, partials: { 'loop' => "{{ value }}", }, ) end def test_render_tag_renders_error_with_template_name assert_template_result( 'Liquid error (foo line 1): standard error', "{% render 'foo' with errors %}", { 'errors' => ErrorDrop.new }, partials: { 'foo' => '{{ foo.standard_error }}' }, render_errors: true, ) end def test_render_tag_renders_error_with_template_name_from_template_factory assert_template_result( 'Liquid error (some/path/foo line 1): standard error', "{% render 'foo' with errors %}", { 'errors' => ErrorDrop.new }, partials: { 'foo' => '{{ foo.standard_error }}' }, template_factory: StubTemplateFactory.new, render_errors: true, ) end def test_render_with_invalid_expression template = '{% render "snippet" with foo=>bar %}' with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end def test_render_attribute_with_invalid_expression template = '{% render "snippet", key: foo=>bar %}' with_error_modes(:lax, :strict) do refute_nil(Template.parse(template)) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end end ================================================ FILE: test/integration/tags/standard_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class StandardTagTest < Minitest::Test include Liquid def test_no_transform assert_template_result( 'this text should come out of the template without change...', 'this text should come out of the template without change...', ) assert_template_result('blah', 'blah') assert_template_result('', '') assert_template_result('|,.:', '|,.:') assert_template_result('', '') text = %(this shouldnt see any transformation either but has multiple lines as you can clearly see here ...) assert_template_result(text, text) end def test_has_a_block_which_does_nothing assert_template_result( %(the comment block should be removed .. right?), %(the comment block should be removed {%comment%} be gone.. {%endcomment%} .. right?), ) assert_template_result('', '{%comment%}{%endcomment%}') assert_template_result('', '{%comment%}{% endcomment %}') assert_template_result('', '{% comment %}{%endcomment%}') assert_template_result('', '{% comment %}{% endcomment %}') assert_template_result('', '{%comment%}comment{%endcomment%}') assert_template_result('', '{% comment %}comment{% endcomment %}') assert_template_result('', '{% comment %} 1 {% comment %} 2 {% endcomment %} 3 {% endcomment %}') assert_template_result('', '{%comment%}{%blabla%}{%endcomment%}') assert_template_result('', '{% comment %}{% blabla %}{% endcomment %}') assert_template_result('', '{%comment%}{% endif %}{%endcomment%}') assert_template_result('', '{% comment %}{% endwhatever %}{% endcomment %}') assert_template_result('', '{% comment %}{% raw %} {{%%%%}} }} { {% endcomment %} {% comment {% endraw %} {% endcomment %}') assert_template_result('', '{% comment %}{% " %}{% endcomment %}') assert_template_result('', '{% comment %}{%%}{% endcomment %}') assert_template_result('foobar', 'foo{%comment%}comment{%endcomment%}bar') assert_template_result('foobar', 'foo{% comment %}comment{% endcomment %}bar') assert_template_result('foobar', 'foo{%comment%} comment {%endcomment%}bar') assert_template_result('foobar', 'foo{% comment %} comment {% endcomment %}bar') assert_template_result('foo bar', 'foo {%comment%} {%endcomment%} bar') assert_template_result('foo bar', 'foo {%comment%}comment{%endcomment%} bar') assert_template_result('foo bar', 'foo {%comment%} comment {%endcomment%} bar') assert_template_result('foobar', 'foo{%comment%} {%endcomment%}bar') end def test_hyphenated_assign assigns = { 'a-b' => '1' } assert_template_result('a-b:1 a-b:2', 'a-b:{{a-b}} {%assign a-b = 2 %}a-b:{{a-b}}', assigns) end def test_assign_with_colon_and_spaces assigns = { 'var' => { 'a:b c' => { 'paged' => '1' } } } assert_template_result('var2: 1', '{%assign var2 = var["a:b c"].paged %}var2: {{var2}}', assigns) end def test_capture assigns = { 'var' => 'content' } assert_template_result( 'content foo content foo ', '{{ var2 }}{% capture var2 %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', assigns, ) end def test_capture_detects_bad_syntax assert_raises(SyntaxError) do assert_template_result( 'content foo content foo ', '{{ var2 }}{% capture %}{{ var }} foo {% endcapture %}{{ var2 }}{{ var2 }}', { 'var' => 'content' }, ) end end def test_case assigns = { 'condition' => 2 } assert_template_result( ' its 2 ', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', assigns, ) assigns = { 'condition' => 1 } assert_template_result( ' its 1 ', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', assigns, ) assigns = { 'condition' => 3 } assert_template_result( '', '{% case condition %}{% when 1 %} its 1 {% when 2 %} its 2 {% endcase %}', assigns, ) assigns = { 'condition' => "string here" } assert_template_result( ' hit ', '{% case condition %}{% when "string here" %} hit {% endcase %}', assigns, ) assigns = { 'condition' => "bad string here" } assert_template_result( '', '{% case condition %}{% when "string here" %} hit {% endcase %}', assigns, ) end def test_case_with_else assigns = { 'condition' => 5 } assert_template_result( ' hit ', '{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', assigns, ) assigns = { 'condition' => 6 } assert_template_result( ' else ', '{% case condition %}{% when 5 %} hit {% else %} else {% endcase %}', assigns, ) assigns = { 'condition' => 6 } assert_template_result( ' else ', '{% case condition %} {% when 5 %} hit {% else %} else {% endcase %}', assigns, ) end def test_case_on_size assert_template_result('', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', { 'a' => [] }) assert_template_result('1', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', { 'a' => [1] }) assert_template_result('2', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', { 'a' => [1, 1] }) assert_template_result('', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', { 'a' => [1, 1, 1] }) assert_template_result('', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', { 'a' => [1, 1, 1, 1] }) assert_template_result('', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% endcase %}', { 'a' => [1, 1, 1, 1, 1] }) end def test_case_on_size_with_else assert_template_result( 'else', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', { 'a' => [] }, ) assert_template_result( '1', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', { 'a' => [1] }, ) assert_template_result( '2', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', { 'a' => [1, 1] }, ) assert_template_result( 'else', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', { 'a' => [1, 1, 1] }, ) assert_template_result( 'else', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', { 'a' => [1, 1, 1, 1] }, ) assert_template_result( 'else', '{% case a.size %}{% when 1 %}1{% when 2 %}2{% else %}else{% endcase %}', { 'a' => [1, 1, 1, 1, 1] }, ) end def test_case_on_length_with_else assert_template_result( 'else', '{% case a.empty? %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}, ) assert_template_result( 'false', '{% case false %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}, ) assert_template_result( 'true', '{% case true %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}, ) assert_template_result( 'else', '{% case NULL %}{% when true %}true{% when false %}false{% else %}else{% endcase %}', {}, ) end def test_assign_from_case # Example from the shopify forums code = "{% case collection.handle %}{% when 'menswear-jackets' %}{% assign ptitle = 'menswear' %}{% when 'menswear-t-shirts' %}{% assign ptitle = 'menswear' %}{% else %}{% assign ptitle = 'womenswear' %}{% endcase %}{{ ptitle }}" assert_template_result("menswear", code, { "collection" => { 'handle' => 'menswear-jackets' } }) assert_template_result("menswear", code, { "collection" => { 'handle' => 'menswear-t-shirts' } }) assert_template_result("womenswear", code, { "collection" => { 'handle' => 'x' } }) assert_template_result("womenswear", code, { "collection" => { 'handle' => 'y' } }) assert_template_result("womenswear", code, { "collection" => { 'handle' => 'z' } }) end def test_case_when_or code = '{% case condition %}{% when 1 or 2 or 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 1 }) assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 2 }) assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 3 }) assert_template_result(' its 4 ', code, { 'condition' => 4 }) assert_template_result('', code, { 'condition' => 5 }) code = '{% case condition %}{% when 1 or "string" or null %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 1 }) assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 'string' }) assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => nil }) assert_template_result('', code, { 'condition' => 'something else' }) end def test_case_when_comma code = '{% case condition %}{% when 1, 2, 3 %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 1 }) assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 2 }) assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 3 }) assert_template_result(' its 4 ', code, { 'condition' => 4 }) assert_template_result('', code, { 'condition' => 5 }) code = '{% case condition %}{% when 1, "string", null %} its 1 or 2 or 3 {% when 4 %} its 4 {% endcase %}' assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 1 }) assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => 'string' }) assert_template_result(' its 1 or 2 or 3 ', code, { 'condition' => nil }) assert_template_result('', code, { 'condition' => 'something else' }) end def test_case_when_comma_and_blank_body code = '{% case condition %}{% when 1, 2 %} {% assign r = "result" %} {% endcase %}{{ r }}' assert_template_result('result', code, { 'condition' => 2 }) end def test_assign assert_template_result('variable', '{% assign a = "variable"%}{{a}}') end def test_assign_unassigned assigns = { 'var' => 'content' } assert_template_result('var2: var2:content', 'var2:{{var2}} {%assign var2 = var%} var2:{{var2}}', assigns) end def test_assign_an_empty_string assert_template_result('', '{% assign a = ""%}{{a}}') end def test_assign_is_global assert_template_result('variable', '{%for i in (1..2) %}{% assign a = "variable"%}{% endfor %}{{a}}') end def test_case_detects_bad_syntax assert_raises(SyntaxError) do assert_template_result('', '{% case false %}{% when %}true{% endcase %}', {}) end assert_raises(SyntaxError) do assert_template_result('', '{% case false %}{% huh %}true{% endcase %}', {}) end end def test_cycle assert_template_result('one', '{%cycle "one", "two"%}') assert_template_result('one two', '{%cycle "one", "two"%} {%cycle "one", "two"%}') assert_template_result(' two', '{%cycle "", "two"%} {%cycle "", "two"%}') assert_template_result('one two one', '{%cycle "one", "two"%} {%cycle "one", "two"%} {%cycle "one", "two"%}') assert_template_result( 'text-align: left text-align: right', '{%cycle "text-align: left", "text-align: right" %} {%cycle "text-align: left", "text-align: right"%}', ) end def test_multiple_cycles assert_template_result( '1 2 1 1 2 3 1', '{%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%} {%cycle 1,2,3%}', ) end def test_multiple_named_cycles assert_template_result( 'one one two two one one', '{%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %} {%cycle 1: "one", "two" %} {%cycle 2: "one", "two" %}', ) end def test_multiple_named_cycles_with_names_from_context assigns = { "var1" => 1, "var2" => 2 } assert_template_result( 'one one two two one one', '{%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %} {%cycle var1: "one", "two" %} {%cycle var2: "one", "two" %}', assigns, ) end def test_size_of_array assigns = { "array" => [1, 2, 3, 4] } assert_template_result('array has 4 elements', "array has {{ array.size }} elements", assigns) end def test_size_of_hash assigns = { "hash" => { a: 1, b: 2, c: 3, d: 4 } } assert_template_result('hash has 4 elements', "hash has {{ hash.size }} elements", assigns) end def test_illegal_symbols assert_template_result('', '{% if true == empty %}?{% endif %}', {}) assert_template_result('', '{% if true == null %}?{% endif %}', {}) assert_template_result('', '{% if empty == true %}?{% endif %}', {}) assert_template_result('', '{% if null == true %}?{% endif %}', {}) end def test_ifchanged assigns = { 'array' => [1, 1, 2, 2, 3, 3] } assert_template_result('123', '{%for item in array%}{%ifchanged%}{{item}}{% endifchanged %}{%endfor%}', assigns) assigns = { 'array' => [1, 1, 1, 1] } assert_template_result('1', '{%for item in array%}{%ifchanged%}{{item}}{% endifchanged %}{%endfor%}', assigns) end def test_multiline_tag assert_template_result('0 1 2 3', "0{%\nfor i in (1..3)\n%} {{\ni\n}}{%\nendfor\n%}") end end # StandardTagTest ================================================ FILE: test/integration/tags/statements_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class StatementsTest < Minitest::Test include Liquid def test_true_eql_true text = ' {% if true == true %} true {% else %} false {% endif %} ' assert_template_result(' true ', text) end def test_true_not_eql_true text = ' {% if true != true %} true {% else %} false {% endif %} ' assert_template_result(' false ', text) end def test_true_lq_true text = ' {% if 0 > 0 %} true {% else %} false {% endif %} ' assert_template_result(' false ', text) end def test_one_lq_zero text = ' {% if 1 > 0 %} true {% else %} false {% endif %} ' assert_template_result(' true ', text) end def test_zero_lq_one text = ' {% if 0 < 1 %} true {% else %} false {% endif %} ' assert_template_result(' true ', text) end def test_zero_lq_or_equal_one text = ' {% if 0 <= 0 %} true {% else %} false {% endif %} ' assert_template_result(' true ', text) end def test_zero_lq_or_equal_one_involving_nil text = ' {% if null <= 0 %} true {% else %} false {% endif %} ' assert_template_result(' false ', text) text = ' {% if 0 <= null %} true {% else %} false {% endif %} ' assert_template_result(' false ', text) end def test_zero_lqq_or_equal_one text = ' {% if 0 >= 0 %} true {% else %} false {% endif %} ' assert_template_result(' true ', text) end def test_strings text = " {% if 'test' == 'test' %} true {% else %} false {% endif %} " assert_template_result(' true ', text) end def test_strings_not_equal text = " {% if 'test' != 'test' %} true {% else %} false {% endif %} " assert_template_result(' false ', text) end def test_var_strings_equal text = ' {% if var == "hello there!" %} true {% else %} false {% endif %} ' assert_template_result(' true ', text, { 'var' => 'hello there!' }) end def test_var_strings_are_not_equal text = ' {% if "hello there!" == var %} true {% else %} false {% endif %} ' assert_template_result(' true ', text, { 'var' => 'hello there!' }) end def test_var_and_long_string_are_equal text = " {% if var == 'hello there!' %} true {% else %} false {% endif %} " assert_template_result(' true ', text, { 'var' => 'hello there!' }) end def test_var_and_long_string_are_equal_backwards text = " {% if 'hello there!' == var %} true {% else %} false {% endif %} " assert_template_result(' true ', text, { 'var' => 'hello there!' }) end # def test_is_nil # text = %| {% if var != nil %} true {% else %} false {% end %} | # @template.assigns = { 'var' => 'hello there!'} # expected = %| true | # assert_equal expected, @template.parse(text) # end def test_is_collection_empty text = ' {% if array == empty %} true {% else %} false {% endif %} ' assert_template_result(' true ', text, { 'array' => [] }) end def test_is_not_collection_empty text = ' {% if array == empty %} true {% else %} false {% endif %} ' assert_template_result(' false ', text, { 'array' => [1, 2, 3] }) end def test_nil text = ' {% if var == nil %} true {% else %} false {% endif %} ' assert_template_result(' true ', text, { 'var' => nil }) text = ' {% if var == null %} true {% else %} false {% endif %} ' assert_template_result(' true ', text, { 'var' => nil }) end def test_not_nil text = ' {% if var != nil %} true {% else %} false {% endif %} ' assert_template_result(' true ', text, { 'var' => 1 }) text = ' {% if var != null %} true {% else %} false {% endif %} ' assert_template_result(' true ', text, { 'var' => 1 }) end end # StatementsTest ================================================ FILE: test/integration/tags/table_row_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TableRowTest < Minitest::Test include Liquid class ArrayDrop < Liquid::Drop include Enumerable def initialize(array) @array = array end def each(&block) @array.each(&block) end end def test_table_row assert_template_result( "\n 1 2 3 \n 4 5 6 \n", '{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}', { 'numbers' => [1, 2, 3, 4, 5, 6] }, ) assert_template_result( "\n\n", '{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}', { 'numbers' => [] }, ) end def test_table_row_with_different_cols assert_template_result( "\n 1 2 3 4 5 \n 6 \n", '{% tablerow n in numbers cols:5%} {{n}} {% endtablerow %}', { 'numbers' => [1, 2, 3, 4, 5, 6] }, ) end def test_table_col_counter assert_template_result( "\n12\n12\n12\n", '{% tablerow n in numbers cols:2%}{{tablerowloop.col}}{% endtablerow %}', { 'numbers' => [1, 2, 3, 4, 5, 6] }, ) end def test_quoted_fragment assert_template_result( "\n 1 2 3 \n 4 5 6 \n", "{% tablerow n in collections.frontpage cols:3%} {{n}} {% endtablerow %}", { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } }, ) assert_template_result( "\n 1 2 3 \n 4 5 6 \n", "{% tablerow n in collections['frontpage'] cols:3%} {{n}} {% endtablerow %}", { 'collections' => { 'frontpage' => [1, 2, 3, 4, 5, 6] } }, ) end def test_enumerable_drop assert_template_result( "\n 1 2 3 \n 4 5 6 \n", '{% tablerow n in numbers cols:3%} {{n}} {% endtablerow %}', { 'numbers' => ArrayDrop.new([1, 2, 3, 4, 5, 6]) }, ) end def test_offset_and_limit assert_template_result( "\n 1 2 3 \n 4 5 6 \n", '{% tablerow n in numbers cols:3 offset:1 limit:6%} {{n}} {% endtablerow %}', { 'numbers' => [0, 1, 2, 3, 4, 5, 6, 7] }, ) end def test_blank_string_not_iterable assert_template_result( "\n\n", "{% tablerow char in characters cols:3 %}I WILL NOT BE OUTPUT{% endtablerow %}", { 'characters' => '' }, ) end def test_cols_nil_constant_same_as_evaluated_nil_expression expect = "\n" \ "false" \ "false" \ "\n" assert_template_result( expect, "{% tablerow i in (1..2) cols:nil %}{{ tablerowloop.col_last }}{% endtablerow %}", ) assert_template_result( expect, "{% tablerow i in (1..2) cols:var %}{{ tablerowloop.col_last }}{% endtablerow %}", { "var" => nil }, ) end def test_nil_limit_is_treated_as_zero expect = "\n" \ "\n" assert_template_result( expect, "{% tablerow i in (1..2) limit:nil %}{{ i }}{% endtablerow %}", ) assert_template_result( expect, "{% tablerow i in (1..2) limit:var %}{{ i }}{% endtablerow %}", { "var" => nil }, ) end def test_nil_offset_is_treated_as_zero expect = "\n" \ "1:false" \ "2:true" \ "\n" assert_template_result( expect, "{% tablerow i in (1..2) offset:nil %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}", ) assert_template_result( expect, "{% tablerow i in (1..2) offset:var %}{{ i }}:{{ tablerowloop.col_last }}{% endtablerow %}", { "var" => nil }, ) end def test_tablerow_loop_drop_attributes template = <<~LIQUID.chomp {% tablerow i in (1..2) %} col: {{ tablerowloop.col }} col0: {{ tablerowloop.col0 }} col_first: {{ tablerowloop.col_first }} col_last: {{ tablerowloop.col_last }} first: {{ tablerowloop.first }} index: {{ tablerowloop.index }} index0: {{ tablerowloop.index0 }} last: {{ tablerowloop.last }} length: {{ tablerowloop.length }} rindex: {{ tablerowloop.rindex }} rindex0: {{ tablerowloop.rindex0 }} row: {{ tablerowloop.row }} {% endtablerow %} LIQUID expected_output = <<~OUTPUT col: 1 col0: 0 col_first: true col_last: false first: true index: 1 index0: 0 last: false length: 2 rindex: 2 rindex0: 1 row: 1 col: 2 col0: 1 col_first: false col_last: true first: false index: 2 index0: 1 last: true length: 2 rindex: 1 rindex0: 0 row: 1 OUTPUT assert_template_result(expected_output, template) end def test_table_row_renders_correct_error_message_for_invalid_parameters assert_template_result( "Liquid error (line 1): invalid integer", '{% tablerow n in (1...10) limit:true %} {{n}} {% endtablerow %}', error_mode: :warn, render_errors: true, ) assert_template_result( "Liquid error (line 1): invalid integer", '{% tablerow n in (1...10) offset:true %} {{n}} {% endtablerow %}', error_mode: :warn, render_errors: true, ) assert_template_result( "Liquid error (line 1): invalid integer", '{% tablerow n in (1...10) cols:true %} {{n}} {% endtablerow %}', render_errors: true, error_mode: :warn, ) end def test_table_row_handles_interrupts assert_template_result( "\n 1 \n", '{% tablerow n in (1..3) cols:2 %} {{n}} {% break %} {{n}} {% endtablerow %}', ) assert_template_result( "\n 1 2 \n 3 \n", '{% tablerow n in (1..3) cols:2 %} {{n}} {% continue %} {{n}} {% endtablerow %}', ) end def test_table_row_does_not_leak_interrupts template = <<~LIQUID {% for i in (1..2) -%} {% for j in (1..2) -%} {% tablerow k in (1..3) %}{% break %}{% endtablerow -%} loop j={{ j }} {% endfor -%} loop i={{ i }} {% endfor -%} after loop LIQUID expected = <<~STR loop j=1 loop j=2 loop i=1 loop j=1 loop j=2 loop i=2 after loop STR assert_template_result( expected, template, ) end def test_tablerow_with_cols_attribute_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..6) cols: 3 %}{{ i }}{% endtablerow %} LIQUID expected = <<~OUTPUT 123 456 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template) end end def test_tablerow_with_limit_attribute_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..10) limit: 3 %}{{ i }}{% endtablerow %} LIQUID expected = <<~OUTPUT 123 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template) end end def test_tablerow_with_offset_attribute_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..5) offset: 2 %}{{ i }}{% endtablerow %} LIQUID expected = <<~OUTPUT 345 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template) end end def test_tablerow_with_range_attribute_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..3) range: (1..10) %}{{ i }}{% endtablerow %} LIQUID expected = <<~OUTPUT 123 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template) end end def test_tablerow_with_multiple_attributes_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..10) cols: 2, limit: 4, offset: 1 %}{{ i }}{% endtablerow %} LIQUID expected = <<~OUTPUT 23 45 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template) end end def test_tablerow_with_variable_collection_in_strict2_mode template = <<~LIQUID.chomp {% tablerow n in numbers cols: 2 %}{{ n }}{% endtablerow %} LIQUID expected = <<~OUTPUT 12 34 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template, { 'numbers' => [1, 2, 3, 4] }) end end def test_tablerow_with_dotted_access_in_strict2_mode template = <<~LIQUID.chomp {% tablerow n in obj.numbers cols: 2 %}{{ n }}{% endtablerow %} LIQUID expected = <<~OUTPUT 12 34 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template, { 'obj' => { 'numbers' => [1, 2, 3, 4] } }) end end def test_tablerow_with_bracketed_access_in_strict2_mode template = <<~LIQUID.chomp {% tablerow n in obj["numbers"] cols: 2 %}{{ n }}{% endtablerow %} LIQUID expected = <<~OUTPUT 1020 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template, { 'obj' => { 'numbers' => [10, 20] } }) end end def test_tablerow_without_attributes_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in (1..3) %}{{ i }}{% endtablerow %} LIQUID expected = <<~OUTPUT 123 OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template) end end def test_tablerow_without_in_keyword_in_strict2_mode template = '{% tablerow i (1..10) %}{{ i }}{% endtablerow %}' with_error_modes(:strict2) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_equal("Liquid syntax error: For loops require an 'in' clause in \"i (1..10)\"", error.message) end end def test_tablerow_with_multiple_invalid_attributes_reports_first_in_strict2_mode template = '{% tablerow i in (1..10) invalid1: 5, invalid2: 10 %}{{ i }}{% endtablerow %}' with_error_modes(:strict2) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_equal("Liquid syntax error: Invalid attribute 'invalid1' in tablerow loop. Valid attributes are cols, limit, offset, and range in \"i in (1..10) invalid1: 5, invalid2: 10\"", error.message) end end def test_tablerow_with_empty_collection_in_strict2_mode template = <<~LIQUID.chomp {% tablerow i in empty_array cols: 2 %}{{ i }}{% endtablerow %} LIQUID expected = <<~OUTPUT OUTPUT with_error_modes(:strict2) do assert_template_result(expected, template, { 'empty_array' => [] }) end end def test_tablerow_with_invalid_attribute_strict_vs_strict2 template = '{% tablerow i in (1..5) invalid_attr: 10 %}{{ i }}{% endtablerow %}' expected = <<~OUTPUT 12345 OUTPUT with_error_modes(:lax, :strict) do assert_template_result(expected, template) end with_error_modes(:strict2) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_match(/Invalid attribute 'invalid_attr'/, error.message) end end def test_tablerow_with_invalid_expression_strict_vs_strict2 template = '{% tablerow i in (1..5) limit: foo=>bar %}{{ i }}{% endtablerow %}' with_error_modes(:lax, :strict) do expected = <<~OUTPUT OUTPUT assert_template_result(expected, template) end with_error_modes(:strict2) do error = assert_raises(SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end end ================================================ FILE: test/integration/tags/unless_else_tag_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class UnlessElseTagTest < Minitest::Test include Liquid def test_unless assert_template_result(' ', ' {% unless true %} this text should not go into the output {% endunless %} ') assert_template_result( ' this text should go into the output ', ' {% unless false %} this text should go into the output {% endunless %} ', ) assert_template_result(' you rock ?', '{% unless true %} you suck {% endunless %} {% unless false %} you rock {% endunless %}?') end def test_unless_else assert_template_result(' YES ', '{% unless true %} NO {% else %} YES {% endunless %}') assert_template_result(' YES ', '{% unless false %} YES {% else %} NO {% endunless %}') assert_template_result(' YES ', '{% unless "foo" %} NO {% else %} YES {% endunless %}') end def test_unless_in_loop assert_template_result('23', '{% for i in choices %}{% unless i %}{{ forloop.index }}{% endunless %}{% endfor %}', { 'choices' => [1, nil, false] }) end def test_unless_else_in_loop assert_template_result(' TRUE 2 3 ', '{% for i in choices %}{% unless i %} {{ forloop.index }} {% else %} TRUE {% endunless %}{% endfor %}', { 'choices' => [1, nil, false] }) end end # UnlessElseTest ================================================ FILE: test/integration/template_test.rb ================================================ # frozen_string_literal: true require 'test_helper' require 'timeout' class TemplateContextDrop < Liquid::Drop def liquid_method_missing(method) method end def foo 'fizzbuzz' end def baz @context.registers['lulz'] end end class SomethingWithLength < Liquid::Drop def length nil end end class ErroneousDrop < Liquid::Drop def bad_method raise 'ruby error in drop' end end class DropWithUndefinedMethod < Liquid::Drop def foo 'foo' end end class TemplateTest < Minitest::Test include Liquid def test_instance_assigns_persist_on_same_template_object_between_parses t = Template.new assert_equal('from instance assigns', t.parse("{% assign foo = 'from instance assigns' %}{{ foo }}").render!) assert_equal('from instance assigns', t.parse("{{ foo }}").render!) end def test_warnings_is_not_exponential_time str = "false" 100.times do str = "{% if true %}true{% else %}#{str}{% endif %}" end t = Template.parse(str) assert_equal([], Timeout.timeout(1) { t.warnings }) end def test_instance_assigns_persist_on_same_template_parsing_between_renders t = Template.new.parse("{{ foo }}{% assign foo = 'foo' %}{{ foo }}") assert_equal('foo', t.render!) assert_equal('foofoo', t.render!) end def test_custom_assigns_do_not_persist_on_same_template t = Template.new assert_equal('from custom assigns', t.parse("{{ foo }}").render!('foo' => 'from custom assigns')) assert_equal('', t.parse("{{ foo }}").render!) end def test_custom_assigns_squash_instance_assigns t = Template.new assert_equal('from instance assigns', t.parse("{% assign foo = 'from instance assigns' %}{{ foo }}").render!) assert_equal('from custom assigns', t.parse("{{ foo }}").render!('foo' => 'from custom assigns')) end def test_persistent_assigns_squash_instance_assigns t = Template.new assert_equal('from instance assigns', t.parse("{% assign foo = 'from instance assigns' %}{{ foo }}").render!) t.assigns['foo'] = 'from persistent assigns' assert_equal('from persistent assigns', t.parse("{{ foo }}").render!) end def test_lambda_is_called_once_from_persistent_assigns_over_multiple_parses_and_renders t = Template.new t.assigns['number'] = -> { @global ||= 0 @global += 1 } assert_equal('1', t.parse("{{number}}").render!) assert_equal('1', t.parse("{{number}}").render!) assert_equal('1', t.render!) @global = nil end def test_lambda_is_called_once_from_custom_assigns_over_multiple_parses_and_renders t = Template.new assigns = { 'number' => -> { @global ||= 0 @global += 1 }, } assert_equal('1', t.parse("{{number}}").render!(assigns)) assert_equal('1', t.parse("{{number}}").render!(assigns)) assert_equal('1', t.render!(assigns)) @global = nil end def test_resource_limits_works_with_custom_length_method t = Template.parse("{% assign foo = bar %}") t.resource_limits.render_length_limit = 42 assert_equal("", t.render!("bar" => SomethingWithLength.new)) end def test_resource_limits_render_length t = Template.parse("0123456789") t.resource_limits.render_length_limit = 9 assert_equal("Liquid error: Memory limits exceeded", t.render) assert(t.resource_limits.reached?) t.resource_limits.render_length_limit = 10 assert_equal("0123456789", t.render!) end def test_resource_limits_render_score t = Template.parse("{% for a in (1..10) %} {% for a in (1..10) %} foo {% endfor %} {% endfor %}") t.resource_limits.render_score_limit = 50 assert_equal("Liquid error: Memory limits exceeded", t.render) assert(t.resource_limits.reached?) t = Template.parse("{% for a in (1..100) %} foo {% endfor %}") t.resource_limits.render_score_limit = 50 assert_equal("Liquid error: Memory limits exceeded", t.render) assert(t.resource_limits.reached?) t.resource_limits.render_score_limit = 200 assert_equal(" foo " * 100, t.render!) refute_nil(t.resource_limits.render_score) end def test_resource_limits_aborts_rendering_after_first_error t = Template.parse("{% for a in (1..100) %} foo1 {% endfor %} bar {% for a in (1..100) %} foo2 {% endfor %}") t.resource_limits.render_score_limit = 50 assert_equal("Liquid error: Memory limits exceeded", t.render) assert(t.resource_limits.reached?) end def test_resource_limits_hash_in_template_gets_updated_even_if_no_limits_are_set t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}") t.render! assert(t.resource_limits.assign_score > 0) assert(t.resource_limits.render_score > 0) end def test_render_length_persists_between_blocks t = Template.parse("{% if true %}aaaa{% endif %}") t.resource_limits.render_length_limit = 3 assert_equal("Liquid error: Memory limits exceeded", t.render) t.resource_limits.render_length_limit = 4 assert_equal("aaaa", t.render) t = Template.parse("{% if true %}aaaa{% endif %}{% if true %}bbb{% endif %}") t.resource_limits.render_length_limit = 6 assert_equal("Liquid error: Memory limits exceeded", t.render) t.resource_limits.render_length_limit = 7 assert_equal("aaaabbb", t.render) t = Template.parse("{% if true %}a{% endif %}{% if true %}b{% endif %}{% if true %}a{% endif %}{% if true %}b{% endif %}{% if true %}a{% endif %}{% if true %}b{% endif %}") t.resource_limits.render_length_limit = 5 assert_equal("Liquid error: Memory limits exceeded", t.render) t.resource_limits.render_length_limit = 6 assert_equal("ababab", t.render) end def test_render_length_uses_number_of_bytes_not_characters t = Template.parse("{% if true %}すごい{% endif %}") t.resource_limits.render_length_limit = 8 assert_equal("Liquid error: Memory limits exceeded", t.render) t.resource_limits.render_length_limit = 9 assert_equal("すごい", t.render) end def test_cumulative_render_score_limit_across_render_tags file_system = StubFileSystem.new( 'loop' => '{% for a in (1..10) %} foo {% endfor %}', ) environment = Liquid::Environment.build(file_system: file_system) # Without cumulative limit, all 5 partials render successfully t = Template.parse( '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', environment: environment, ) unlimited_output = t.render! total_cumulative = t.resource_limits.cumulative_render_score # With cumulative limit set below the total, rendering stops early t2 = Template.parse( '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', environment: environment, ) t2.resource_limits.cumulative_render_score_limit = total_cumulative / 2 limited_output = t2.render assert(t2.resource_limits.reached?) assert_operator(limited_output.length, :<, unlimited_output.length) end def test_cumulative_render_score_limit_raises_on_render_bang file_system = StubFileSystem.new( 'loop' => '{% for a in (1..10) %} foo {% endfor %}', ) environment = Liquid::Environment.build(file_system: file_system) t = Template.parse( '{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}{% render "loop" %}', environment: environment, ) t.resource_limits.cumulative_render_score_limit = 20 assert_raises(Liquid::MemoryError) do t.render! end end def test_cumulative_assign_score_limit_across_include_tags file_system = StubFileSystem.new( 'assign_partial' => '{% assign x = "a long string value here" %}', ) environment = Liquid::Environment.build(file_system: file_system) # Without cumulative limit, all 5 partials render t = Template.parse( '{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}', environment: environment, ) t.render! total_cumulative = t.resource_limits.cumulative_assign_score # With cumulative limit set below the total, rendering stops early t2 = Template.parse( '{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}{% include "assign_partial" %}', environment: environment, ) t2.resource_limits.cumulative_assign_score_limit = total_cumulative / 2 t2.render assert(t2.resource_limits.reached?) end def test_cumulative_render_score_tracks_across_partials_without_limit file_system = StubFileSystem.new( 'loop' => '{% for a in (1..10) %} foo {% endfor %}', ) environment = Liquid::Environment.build(file_system: file_system) t = Template.parse( '{% render "loop" %}{% render "loop" %}{% render "loop" %}', environment: environment, ) t.render! assert( t.resource_limits.cumulative_render_score > t.resource_limits.render_score, "cumulative should exceed per-template score after multiple partials", ) end def test_default_resource_limits_unaffected_by_render_with_context context = Context.new t = Template.parse("{% for a in (1..100) %}x{% assign foo = 1 %} {% endfor %}") t.render!(context) assert(context.resource_limits.assign_score > 0) assert(context.resource_limits.render_score > 0) end def test_can_use_drop_as_context t = Template.new t.registers['lulz'] = 'haha' drop = TemplateContextDrop.new assert_equal('fizzbuzz', t.parse('{{foo}}').render!(drop)) assert_equal('bar', t.parse('{{bar}}').render!(drop)) assert_equal('haha', t.parse("{{baz}}").render!(drop)) end def test_render_bang_force_rethrow_errors_on_passed_context context = Context.new('drop' => ErroneousDrop.new) t = Template.new.parse('{{ drop.bad_method }}') e = assert_raises(RuntimeError) do t.render!(context) end assert_equal('ruby error in drop', e.message) end def test_exception_renderer_that_returns_string exception = nil handler = ->(e) { exception = e '' } output = Template.parse("{{ 1 | divided_by: 0 }}").render({}, exception_renderer: handler) assert(exception.is_a?(Liquid::ZeroDivisionError)) assert_equal('', output) end def test_exception_renderer_that_raises exception = nil assert_raises(Liquid::ZeroDivisionError) do Template.parse("{{ 1 | divided_by: 0 }}").render({}, exception_renderer: ->(e) { exception = e raise }) end assert(exception.is_a?(Liquid::ZeroDivisionError)) end def test_global_filter_option_on_render global_filter_proc = ->(output) { "#{output} filtered" } rendered_template = Template.parse("{{name}}").render({ "name" => "bob" }, global_filter: global_filter_proc) assert_equal('bob filtered', rendered_template) end def test_global_filter_option_when_native_filters_exist global_filter_proc = ->(output) { "#{output} filtered" } rendered_template = Template.parse("{{name | upcase}}").render({ "name" => "bob" }, global_filter: global_filter_proc) assert_equal('BOB filtered', rendered_template) end def test_undefined_variables t = Template.parse("{{x}} {{y}} {{z.a}} {{z.b}} {{z.c.d}}") result = t.render({ 'x' => 33, 'z' => { 'a' => 32, 'c' => { 'e' => 31 } } }, strict_variables: true) assert_equal('33 32 ', result) assert_equal(3, t.errors.count) assert_instance_of(Liquid::UndefinedVariable, t.errors[0]) assert_equal('Liquid error: undefined variable y', t.errors[0].message) assert_instance_of(Liquid::UndefinedVariable, t.errors[1]) assert_equal('Liquid error: undefined variable b', t.errors[1].message) assert_instance_of(Liquid::UndefinedVariable, t.errors[2]) assert_equal('Liquid error: undefined variable d', t.errors[2].message) end def test_nil_value_does_not_raise t = Template.parse("some{{x}}thing", error_mode: :strict) result = t.render!({ 'x' => nil }, strict_variables: true) assert_equal(0, t.errors.count) assert_equal('something', result) end def test_undefined_variables_raise t = Template.parse("{{x}} {{y}} {{z.a}} {{z.b}} {{z.c.d}}") assert_raises(UndefinedVariable) do t.render!({ 'x' => 33, 'z' => { 'a' => 32, 'c' => { 'e' => 31 } } }, strict_variables: true) end end def test_undefined_drop_methods d = DropWithUndefinedMethod.new t = Template.new.parse('{{ foo }} {{ woot }}') result = t.render(d, strict_variables: true) assert_equal('foo ', result) assert_equal(1, t.errors.count) assert_instance_of(Liquid::UndefinedDropMethod, t.errors[0]) end def test_undefined_drop_methods_raise d = DropWithUndefinedMethod.new t = Template.new.parse('{{ foo }} {{ woot }}') assert_raises(UndefinedDropMethod) do t.render!(d, strict_variables: true) end end def test_undefined_filters t = Template.parse("{{a}} {{x | upcase | somefilter1 | somefilter2 | somefilter3}}") filters = Module.new do def somefilter3(v) "-#{v}-" end end result = t.render({ 'a' => 123, 'x' => 'foo' }, filters: [filters], strict_filters: true) assert_equal('123 ', result) assert_equal(1, t.errors.count) assert_instance_of(Liquid::UndefinedFilter, t.errors[0]) assert_equal('Liquid error: undefined filter somefilter1', t.errors[0].message) end def test_undefined_filters_raise t = Template.parse("{{x | somefilter1 | upcase | somefilter2}}") assert_raises(UndefinedFilter) do t.render!({ 'x' => 'foo' }, strict_filters: true) end end def test_using_range_literal_works_as_expected source = "{% assign foo = (x..y) %}{{ foo }}" assert_template_result("1..5", source, { "x" => 1, "y" => 5 }) source = "{% assign nums = (x..y) %}{% for num in nums %}{{ num }}{% endfor %}" assert_template_result("12345", source, { "x" => 1, "y" => 5 }) end def test_source_string_subclass string_subclass = Class.new(String) do # E.g. ActiveSupport::SafeBuffer does this, so don't just rely on to_s to return a String def to_s self end end source = string_subclass.new("{% assign x = 2 -%} x= {{- x }}") assert_instance_of(string_subclass, source) output = Template.parse(source).render! assert_equal("x=2", output) assert_instance_of(String, output) end def test_raises_error_with_invalid_utf8 e = assert_raises(TemplateEncodingError) do Template.parse(<<~LIQUID) {% comment %} \xC0 {% endcomment %} LIQUID end assert_equal('Liquid error: Invalid template encoding', e.message) end def test_allows_non_string_values_as_source assert_equal('', Template.parse(nil).render) assert_equal('1', Template.parse(1).render) assert_equal('true', Template.parse(true).render) end end ================================================ FILE: test/integration/trim_mode_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TrimModeTest < Minitest::Test include Liquid # Make sure the trim isn't applied to standard output def test_standard_output text = <<-END_TEMPLATE

{{ 'John' }}

END_TEMPLATE expected = <<-END_EXPECTED

John

END_EXPECTED assert_template_result(expected, text) end def test_variable_output_with_multiple_blank_lines text = <<-END_TEMPLATE

{{- 'John' -}}

END_TEMPLATE expected = <<-END_EXPECTED

John

END_EXPECTED assert_template_result(expected, text) end def test_tag_output_with_multiple_blank_lines text = <<-END_TEMPLATE

{%- if true -%} yes {%- endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

yes

END_EXPECTED assert_template_result(expected, text) end # Make sure the trim isn't applied to standard tags def test_standard_tags whitespace = ' ' text = <<-END_TEMPLATE

{% if true %} yes {% endif %}

END_TEMPLATE expected = <<~END_EXPECTED

#{whitespace} yes #{whitespace}

END_EXPECTED assert_template_result(expected, text) text = <<-END_TEMPLATE

{% if false %} no {% endif %}

END_TEMPLATE expected = <<~END_EXPECTED

#{whitespace}

END_EXPECTED assert_template_result(expected, text) end # Make sure the trim isn't too agressive def test_no_trim_output text = '

{{- \'John\' -}}

' expected = '

John

' assert_template_result(expected, text) end # Make sure the trim isn't too agressive def test_no_trim_tags text = '

{%- if true -%}yes{%- endif -%}

' expected = '

yes

' assert_template_result(expected, text) text = '

{%- if false -%}no{%- endif -%}

' expected = '

' assert_template_result(expected, text) end def test_single_line_outer_tag text = '

{%- if true %} yes {% endif -%}

' expected = '

yes

' assert_template_result(expected, text) text = '

{%- if false %} no {% endif -%}

' expected = '

' assert_template_result(expected, text) end def test_single_line_inner_tag text = '

{% if true -%} yes {%- endif %}

' expected = '

yes

' assert_template_result(expected, text) text = '

{% if false -%} no {%- endif %}

' expected = '

' assert_template_result(expected, text) end def test_single_line_post_tag text = '

{% if true -%} yes {% endif -%}

' expected = '

yes

' assert_template_result(expected, text) text = '

{% if false -%} no {% endif -%}

' expected = '

' assert_template_result(expected, text) end def test_single_line_pre_tag text = '

{%- if true %} yes {%- endif %}

' expected = '

yes

' assert_template_result(expected, text) text = '

{%- if false %} no {%- endif %}

' expected = '

' assert_template_result(expected, text) end def test_pre_trim_output text = <<-END_TEMPLATE

{{- 'John' }}

END_TEMPLATE expected = <<-END_EXPECTED

John

END_EXPECTED assert_template_result(expected, text) end def test_pre_trim_tags text = <<-END_TEMPLATE

{%- if true %} yes {%- endif %}

END_TEMPLATE expected = <<-END_EXPECTED

yes

END_EXPECTED assert_template_result(expected, text) text = <<-END_TEMPLATE

{%- if false %} no {%- endif %}

END_TEMPLATE expected = <<-END_EXPECTED

END_EXPECTED assert_template_result(expected, text) end def test_post_trim_output text = <<-END_TEMPLATE

{{ 'John' -}}

END_TEMPLATE expected = <<-END_EXPECTED

John

END_EXPECTED assert_template_result(expected, text) end def test_post_trim_tags text = <<-END_TEMPLATE

{% if true -%} yes {% endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

yes

END_EXPECTED assert_template_result(expected, text) text = <<-END_TEMPLATE

{% if false -%} no {% endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

END_EXPECTED assert_template_result(expected, text) end def test_pre_and_post_trim_tags text = <<-END_TEMPLATE

{%- if true %} yes {% endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

yes

END_EXPECTED assert_template_result(expected, text) text = <<-END_TEMPLATE

{%- if false %} no {% endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

END_EXPECTED assert_template_result(expected, text) end def test_post_and_pre_trim_tags text = <<-END_TEMPLATE

{% if true -%} yes {%- endif %}

END_TEMPLATE expected = <<-END_EXPECTED

yes

END_EXPECTED assert_template_result(expected, text) whitespace = ' ' text = <<-END_TEMPLATE

{% if false -%} no {%- endif %}

END_TEMPLATE expected = <<~END_EXPECTED

#{whitespace}

END_EXPECTED assert_template_result(expected, text) end def test_trim_output text = <<-END_TEMPLATE

{{- 'John' -}}

END_TEMPLATE expected = <<-END_EXPECTED

John

END_EXPECTED assert_template_result(expected, text) end def test_trim_tags text = <<-END_TEMPLATE

{%- if true -%} yes {%- endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

yes

END_EXPECTED assert_template_result(expected, text) text = <<-END_TEMPLATE

{%- if false -%} no {%- endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

END_EXPECTED assert_template_result(expected, text) end def test_whitespace_trim_output text = <<-END_TEMPLATE

{{- 'John' -}}, {{- '30' -}}

END_TEMPLATE expected = <<-END_EXPECTED

John,30

END_EXPECTED assert_template_result(expected, text) end def test_whitespace_trim_tags text = <<-END_TEMPLATE

{%- if true -%} yes {%- endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

yes

END_EXPECTED assert_template_result(expected, text) text = <<-END_TEMPLATE

{%- if false -%} no {%- endif -%}

END_TEMPLATE expected = <<-END_EXPECTED

END_EXPECTED assert_template_result(expected, text) end def test_complex_trim_output text = <<-END_TEMPLATE

{{- 'John' -}} {{- '30' -}}

{{ 'John' -}} {{- '30' }} {{- 'John' }} {{ '30' -}}
END_TEMPLATE expected = <<-END_EXPECTED

John30

John30 John 30
END_EXPECTED assert_template_result(expected, text) end def test_complex_trim text = <<-END_TEMPLATE
{%- if true -%} {%- if true -%}

{{- 'John' -}}

{%- endif -%} {%- endif -%}
END_TEMPLATE expected = <<-END_EXPECTED

John

END_EXPECTED assert_template_result(expected, text) end def test_right_trim_followed_by_tag assert_template_result('ab c', '{{ "a" -}}{{ "b" }} c') end def test_raw_output whitespace = ' ' text = <<-END_TEMPLATE
{% raw %} {%- if true -%}

{{- 'John' -}}

{%- endif -%} {% endraw %}
END_TEMPLATE expected = <<~END_EXPECTED
#{whitespace} {%- if true -%}

{{- 'John' -}}

{%- endif -%} #{whitespace}
END_EXPECTED assert_template_result(expected, text) end def test_pre_trim_blank_preceding_text assert_template_result("", "\n{%- raw %}{% endraw %}") assert_template_result("", "\n{%- if true %}{% endif %}") assert_template_result("BC", "{{ 'B' }} \n{%- if true %}C{% endif %}") end def test_bug_compatible_pre_trim template = Liquid::Template.parse("\n {%- raw %}{% endraw %}", bug_compatible_whitespace_trimming: true) assert_equal("\n", template.render) template = Liquid::Template.parse("\n {%- if true %}{% endif %}", bug_compatible_whitespace_trimming: true) assert_equal("\n", template.render) template = Liquid::Template.parse("{{ 'B' }} \n{%- if true %}C{% endif %}", bug_compatible_whitespace_trimming: true) assert_equal("B C", template.render) template = Liquid::Template.parse("B\n {%- raw %}{% endraw %}", bug_compatible_whitespace_trimming: true) assert_equal("B", template.render) template = Liquid::Template.parse("B\n {%- if true %}{% endif %}", bug_compatible_whitespace_trimming: true) assert_equal("B", template.render) end def test_trim_blank assert_template_result('foobar', 'foo {{-}} bar') end end # TrimModeTest ================================================ FILE: test/integration/variable_test.rb ================================================ # frozen_string_literal: true require 'test_helper' require 'timeout' class VariableTest < Minitest::Test include Liquid def test_simple_variable assert_template_result('worked', "{{test}}", { 'test' => 'worked' }) assert_template_result('worked wonderfully', "{{test}}", { 'test' => 'worked wonderfully' }) end def test_variable_render_calls_to_liquid assert_template_result('foobar', '{{ foo }}', { 'foo' => ThingWithToLiquid.new }) end def test_variable_lookup_calls_to_liquid_value assert_template_result('1', '{{ foo }}', { 'foo' => IntegerDrop.new('1') }) assert_template_result('2', '{{ list[foo] }}', { 'foo' => IntegerDrop.new('1'), 'list' => [1, 2, 3] }) assert_template_result('one', '{{ list[foo] }}', { 'foo' => IntegerDrop.new('1'), 'list' => { 1 => 'one' } }) assert_template_result('Yay', '{{ foo }}', { 'foo' => BooleanDrop.new(true) }) assert_template_result('YAY', '{{ foo | upcase }}', { 'foo' => BooleanDrop.new(true) }) end def test_if_tag_calls_to_liquid_value assert_template_result('one', '{% if foo == 1 %}one{% endif %}', { 'foo' => IntegerDrop.new('1') }) assert_template_result('one', '{% if foo == eqv %}one{% endif %}', { 'foo' => IntegerDrop.new(1), 'eqv' => IntegerDrop.new(1) }) assert_template_result('one', '{% if 0 < foo %}one{% endif %}', { 'foo' => IntegerDrop.new('1') }) assert_template_result('one', '{% if foo > 0 %}one{% endif %}', { 'foo' => IntegerDrop.new('1') }) assert_template_result('one', '{% if b > a %}one{% endif %}', { 'b' => IntegerDrop.new(1), 'a' => IntegerDrop.new(0) }) assert_template_result('true', '{% if foo == true %}true{% endif %}', { 'foo' => BooleanDrop.new(true) }) assert_template_result('true', '{% if foo %}true{% endif %}', { 'foo' => BooleanDrop.new(true) }) assert_template_result('', '{% if foo %}true{% endif %}', { 'foo' => BooleanDrop.new(false) }) assert_template_result('', '{% if foo == true %}True{% endif %}', { 'foo' => BooleanDrop.new(false) }) assert_template_result('', '{% if foo and true %}SHOULD NOT HAPPEN{% endif %}', { 'foo' => BooleanDrop.new(false) }) assert_template_result('one', '{% if a contains x %}one{% endif %}', { 'a' => [1], 'x' => IntegerDrop.new(1) }) end def test_unless_tag_calls_to_liquid_value assert_template_result('', '{% unless foo %}true{% endunless %}', { 'foo' => BooleanDrop.new(true) }) assert_template_result('true', '{% unless foo %}true{% endunless %}', { 'foo' => BooleanDrop.new(false) }) end def test_case_tag_calls_to_liquid_value assert_template_result('One', '{% case foo %}{% when 1 %}One{% endcase %}', { 'foo' => IntegerDrop.new('1') }) end def test_simple_with_whitespaces assert_template_result(" worked ", " {{ test }} ", { "test" => "worked" }) assert_template_result(" worked wonderfully ", " {{ test }} ", { "test" => "worked wonderfully" }) end def test_expression_with_whitespace_in_square_brackets assert_template_result('result', "{{ a[ 'b' ] }}", { 'a' => { 'b' => 'result' } }) assert_template_result('result', "{{ a[ [ 'b' ] ] }}", { 'b' => 'c', 'a' => { 'c' => 'result' } }) end def test_ignore_unknown assert_template_result("", "{{ test }}") end def test_using_blank_as_variable_name assert_template_result("", "{% assign foo = blank %}{{ foo }}") end def test_using_empty_as_variable_name assert_template_result("", "{% assign foo = empty %}{{ foo }}") end def test_hash_scoping assert_template_result('worked', "{{ test.test }}", { 'test' => { 'test' => 'worked' } }) assert_template_result('worked', "{{ test . test }}", { 'test' => { 'test' => 'worked' } }) end def test_false_renders_as_false assert_template_result("false", "{{ foo }}", { 'foo' => false }) assert_template_result("false", "{{ false }}") end def test_nil_renders_as_empty_string assert_template_result("", "{{ nil }}") assert_template_result("cat", "{{ nil | append: 'cat' }}") end def test_preset_assigns template = Template.parse(%({{ test }})) template.assigns['test'] = 'worked' assert_equal('worked', template.render!) end def test_reuse_parsed_template template = Template.parse(%({{ greeting }} {{ name }})) template.assigns['greeting'] = 'Goodbye' assert_equal('Hello Tobi', template.render!('greeting' => 'Hello', 'name' => 'Tobi')) assert_equal('Hello ', template.render!('greeting' => 'Hello', 'unknown' => 'Tobi')) assert_equal('Hello Brian', template.render!('greeting' => 'Hello', 'name' => 'Brian')) assert_equal('Goodbye Brian', template.render!('name' => 'Brian')) assert_equal({ 'greeting' => 'Goodbye' }, template.assigns) end def test_assigns_not_polluted_from_template template = Template.parse(%({{ test }}{% assign test = 'bar' %}{{ test }})) template.assigns['test'] = 'baz' assert_equal('bazbar', template.render!) assert_equal('bazbar', template.render!) assert_equal('foobar', template.render!('test' => 'foo')) assert_equal('bazbar', template.render!) end def test_hash_with_default_proc template = Template.parse(%(Hello {{ test }})) assigns = Hash.new { |_h, k| raise "Unknown variable '#{k}'" } assigns['test'] = 'Tobi' assert_equal('Hello Tobi', template.render!(assigns)) assigns.delete('test') e = assert_raises(RuntimeError) do template.render!(assigns) end assert_equal("Unknown variable 'test'", e.message) end def test_multiline_variable assert_template_result("worked", "{{\ntest\n}}", { "test" => "worked" }) end def test_render_symbol assert_template_result('bar', '{{ foo }}', { 'foo' => :bar }) end def test_nested_array assert_template_result('', '{{ foo }}', { 'foo' => [[nil]] }) end def test_dynamic_find_var assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) end def test_raw_value_variable assert_template_result('bar', '{{ [key] }}', { 'key' => 'foo', 'foo' => 'bar' }) end def test_dynamic_find_var_with_drop assert_template_result( 'bar', '{{ [list[settings.zero]] }}', { 'list' => ['foo'], 'settings' => SettingsDrop.new("zero" => 0), 'foo' => 'bar', }, ) assert_template_result( 'foo', '{{ [list[settings.zero]["foo"]] }}', { 'list' => [{ 'foo' => 'bar' }], 'settings' => SettingsDrop.new("zero" => 0), 'bar' => 'foo', }, ) end def test_double_nested_variable_lookup assert_template_result( 'bar', '{{ list[list[settings.zero]]["foo"] }}', { 'list' => [1, { 'foo' => 'bar' }], 'settings' => SettingsDrop.new("zero" => 0), 'bar' => 'foo', }, ) end def test_variable_lookup_should_not_hang_with_invalid_syntax Timeout.timeout(1) do assert_template_result( 'bar', "{{['foo'}}", { 'foo' => 'bar', }, error_mode: :lax, ) end very_long_key = "1234567890" * 100 template_list = [ "{{['#{very_long_key}']}}", # valid "{{['#{very_long_key}'}}", # missing closing bracket "{{[['#{very_long_key}']}}", # extra open bracket ] template_list.each do |template| Timeout.timeout(1) do assert_template_result( 'bar', template, { very_long_key => 'bar', }, error_mode: :lax, ) end end end def test_filter_with_single_trailing_comma template = '{{ "hello" | append: "world", }}' with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end with_error_modes(:strict2) do assert_template_result('helloworld', template) end end def test_multiple_filters_with_trailing_commas template = '{{ "hello" | append: "1", | append: "2", }}' with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end with_error_modes(:strict2) do assert_template_result('hello12', template) end end def test_filter_with_colon_but_no_arguments template = '{{ "test" | upcase: }}' with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end with_error_modes(:strict2) do assert_template_result('TEST', template) end end def test_filter_chain_with_colon_no_args template = '{{ "test" | append: "x" | upcase: }}' with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end with_error_modes(:strict2) do assert_template_result('TESTX', template) end end def test_combining_trailing_comma_and_empty_args template = '{{ "test" | append: "x", | upcase: }}' with_error_modes(:strict) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/is not a valid expression/, error.message) end with_error_modes(:strict2) do assert_template_result('TESTX', template) end end end ================================================ FILE: test/test_helper.rb ================================================ #!/usr/bin/env ruby # frozen_string_literal: true ENV["MT_NO_EXPECTATIONS"] = "1" require 'minitest/autorun' $LOAD_PATH.unshift(File.join(File.expand_path(__dir__), '..', 'lib')) require 'liquid.rb' require 'liquid/profiler' mode = :strict if (env_mode = ENV['LIQUID_PARSER_MODE']) puts "-- #{env_mode.upcase} ERROR MODE" mode = env_mode.to_sym end Liquid::Environment.default.error_mode = mode if Minitest.const_defined?('Test') # We're on Minitest 5+. Nothing to do here. else # Minitest 4 doesn't have Minitest::Test yet. Minitest::Test = MiniTest::Unit::TestCase end module Minitest class Test def fixture(name) File.join(File.expand_path(__dir__), "fixtures", name) end end module Assertions include Liquid def assert_template_result( expected, template, assigns = {}, message: nil, partials: nil, error_mode: Liquid::Environment.default.error_mode, render_errors: false, template_factory: nil ) file_system = StubFileSystem.new(partials || {}) environment = Liquid::Environment.build(file_system: file_system) template = Liquid::Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym, environment: environment) registers = Liquid::Registers.new(file_system: file_system, template_factory: template_factory) context = Liquid::Context.build(static_environments: assigns, rethrow_errors: !render_errors, registers: registers, environment: environment) output = template.render(context) assert_equal(expected, output, message) end def assert_match_syntax_error(match, template, error_mode: nil) exception = assert_raises(Liquid::SyntaxError) do Template.parse(template, line_numbers: true, error_mode: error_mode&.to_sym).render end assert_match(match, exception.message) end def assert_syntax_error(template, error_mode: nil) assert_match_syntax_error("", template, error_mode: error_mode) end def assert_usage_increment(name, times: 1) old_method = Liquid::Usage.method(:increment) calls = 0 begin Liquid::Usage.singleton_class.send(:remove_method, :increment) Liquid::Usage.define_singleton_method(:increment) do |got_name| calls += 1 if got_name == name old_method.call(got_name) end yield ensure Liquid::Usage.singleton_class.send(:remove_method, :increment) Liquid::Usage.define_singleton_method(:increment, old_method) end assert_equal(times, calls, "Number of calls to Usage.increment with #{name.inspect}") end def with_global_filter(*globals, &blk) environment = Liquid::Environment.build do |w| w.register_filters(globals) end Environment.dangerously_override(environment, &blk) end def with_error_modes(*modes) old_mode = Liquid::Environment.default.error_mode modes.each do |mode| Liquid::Environment.default.error_mode = mode yield end ensure Liquid::Environment.default.error_mode = old_mode end def with_custom_tag(tag_name, tag_class, &block) environment = Liquid::Environment.default.dup environment.register_tag(tag_name, tag_class) Environment.dangerously_override(environment, &block) end end end class ThingWithToLiquid def to_liquid 'foobar' end end class SettingsDrop < Liquid::Drop def initialize(settings) super() @settings = settings end def liquid_method_missing(key) @settings[key] end end class IntegerDrop < Liquid::Drop def initialize(value) super() @value = value.to_i end def to_s @value.to_s end def to_liquid_value @value end end class BooleanDrop < Liquid::Drop def initialize(value) super() @value = value end def to_liquid_value @value end def to_s @value ? "Yay" : "Nay" end end class StringDrop < Liquid::Drop include Comparable def initialize(value) super() @value = value end def to_liquid_value @value end def to_s @value end def to_str @value end def inspect "#" end def <=>(other) to_liquid_value <=> Liquid::Utils.to_liquid_value(other) end end class ErrorDrop < Liquid::Drop def standard_error raise Liquid::StandardError, 'standard error' end def argument_error raise Liquid::ArgumentError, 'argument error' end def syntax_error raise Liquid::SyntaxError, 'syntax error' end def runtime_error raise 'runtime error' end def exception raise Exception, 'exception' end end class CustomToLiquidDrop < Liquid::Drop def initialize(value) @value = value super() end def to_liquid @value end end class HashWithCustomToS < Hash def to_s "kewl" end end class HashWithoutCustomToS < Hash end class StubFileSystem attr_reader :file_read_count def initialize(values) @file_read_count = 0 @values = values end def read_template_file(template_path) @file_read_count += 1 @values.fetch(template_path) end end class StubTemplateFactory attr_reader :count def initialize @count = 0 end def for(template_name) @count += 1 template = Liquid::Template.new template.name = "some/path/" + template_name template end end ================================================ FILE: test/unit/block_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class BlockUnitTest < Minitest::Test include Liquid def test_blankspace template = Liquid::Template.parse(" ") assert_equal([" "], template.root.nodelist) end def test_variable_beginning template = Liquid::Template.parse("{{funk}} ") assert_equal(2, template.root.nodelist.size) assert_equal(Variable, template.root.nodelist[0].class) assert_equal(String, template.root.nodelist[1].class) end def test_variable_end template = Liquid::Template.parse(" {{funk}}") assert_equal(2, template.root.nodelist.size) assert_equal(String, template.root.nodelist[0].class) assert_equal(Variable, template.root.nodelist[1].class) end def test_variable_middle template = Liquid::Template.parse(" {{funk}} ") assert_equal(3, template.root.nodelist.size) assert_equal(String, template.root.nodelist[0].class) assert_equal(Variable, template.root.nodelist[1].class) assert_equal(String, template.root.nodelist[2].class) end def test_variable_with_multibyte_character template = Liquid::Template.parse("{{ '❤️' }}") assert_equal(1, template.root.nodelist.size) assert_equal(Variable, template.root.nodelist[0].class) end def test_variable_many_embedded_fragments template = Liquid::Template.parse(" {{funk}} {{so}} {{brother}} ") assert_equal(7, template.root.nodelist.size) assert_equal( [String, Variable, String, Variable, String, Variable, String], block_types(template.root.nodelist), ) end def test_comment_tag_with_block template = Liquid::Template.parse(" {% comment %} {% endcomment %} ") assert_equal([String, Comment, String], block_types(template.root.nodelist)) assert_equal(3, template.root.nodelist.size) end def test_doc_tag_with_block template = Liquid::Template.parse(" {% doc %} {% enddoc %} ") assert_equal([String, Doc, String], block_types(template.root.nodelist)) assert_equal(3, template.root.nodelist.size) end private def block_types(nodelist) nodelist.collect(&:class) end end ================================================ FILE: test/unit/condition_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ConditionUnitTest < Minitest::Test include Liquid def setup @context = Liquid::Context.new end def test_basic_condition assert_equal(false, Condition.new(1, '==', 2).evaluate(Context.new)) assert_equal(true, Condition.new(1, '==', 1).evaluate(Context.new)) end def test_default_operators_evalute_true assert_evaluates_true(1, '==', 1) assert_evaluates_true(1, '!=', 2) assert_evaluates_true(1, '<>', 2) assert_evaluates_true(1, '<', 2) assert_evaluates_true(2, '>', 1) assert_evaluates_true(1, '>=', 1) assert_evaluates_true(2, '>=', 1) assert_evaluates_true(1, '<=', 2) assert_evaluates_true(1, '<=', 1) # negative numbers assert_evaluates_true(1, '>', -1) assert_evaluates_true(-1, '<', 1) assert_evaluates_true(1.0, '>', -1.0) assert_evaluates_true(-1.0, '<', 1.0) end def test_default_operators_evalute_false assert_evaluates_false(1, '==', 2) assert_evaluates_false(1, '!=', 1) assert_evaluates_false(1, '<>', 1) assert_evaluates_false(1, '<', 0) assert_evaluates_false(2, '>', 4) assert_evaluates_false(1, '>=', 3) assert_evaluates_false(2, '>=', 4) assert_evaluates_false(1, '<=', 0) assert_evaluates_false(1, '<=', 0) end def test_contains_works_on_strings assert_evaluates_true('bob', 'contains', 'o') assert_evaluates_true('bob', 'contains', 'b') assert_evaluates_true('bob', 'contains', 'bo') assert_evaluates_true('bob', 'contains', 'ob') assert_evaluates_true('bob', 'contains', 'bob') assert_evaluates_false('bob', 'contains', 'bob2') assert_evaluates_false('bob', 'contains', 'a') assert_evaluates_false('bob', 'contains', '---') end def test_contains_binary_encoding_compatibility_with_utf8 assert_evaluates_true('🙈'.b, 'contains', '🙈') assert_evaluates_true('🙈', 'contains', '🙈'.b) end def test_invalid_comparation_operator assert_evaluates_argument_error(1, '~~', 0) end def test_comparation_of_int_and_str assert_evaluates_argument_error('1', '>', 0) assert_evaluates_argument_error('1', '<', 0) assert_evaluates_argument_error('1', '>=', 0) assert_evaluates_argument_error('1', '<=', 0) end def test_hash_compare_backwards_compatibility assert_nil(Condition.new({}, '>', 2).evaluate(Context.new)) assert_nil(Condition.new(2, '>', {}).evaluate(Context.new)) assert_equal(false, Condition.new({}, '==', 2).evaluate(Context.new)) assert_equal(true, Condition.new({ 'a' => 1 }, '==', 'a' => 1).evaluate(Context.new)) assert_equal(true, Condition.new({ 'a' => 2 }, 'contains', 'a').evaluate(Context.new)) end def test_contains_works_on_arrays @context = Liquid::Context.new @context['array'] = [1, 2, 3, 4, 5] array_expr = VariableLookup.new("array") assert_evaluates_false(array_expr, 'contains', 0) assert_evaluates_true(array_expr, 'contains', 1) assert_evaluates_true(array_expr, 'contains', 2) assert_evaluates_true(array_expr, 'contains', 3) assert_evaluates_true(array_expr, 'contains', 4) assert_evaluates_true(array_expr, 'contains', 5) assert_evaluates_false(array_expr, 'contains', 6) assert_evaluates_false(array_expr, 'contains', "1") end def test_contains_returns_false_for_nil_operands @context = Liquid::Context.new assert_evaluates_false(VariableLookup.new('not_assigned'), 'contains', '0') assert_evaluates_false(0, 'contains', VariableLookup.new('not_assigned')) end def test_contains_return_false_on_wrong_data_type assert_evaluates_false(1, 'contains', 0) end def test_contains_with_string_left_operand_coerces_right_operand_to_string assert_evaluates_true(' 1 ', 'contains', 1) assert_evaluates_false(' 1 ', 'contains', 2) end def test_or_condition condition = Condition.new(1, '==', 2) assert_equal(false, condition.evaluate(Context.new)) condition.or(Condition.new(2, '==', 1)) assert_equal(false, condition.evaluate(Context.new)) condition.or(Condition.new(1, '==', 1)) assert_equal(true, condition.evaluate(Context.new)) end def test_and_condition condition = Condition.new(1, '==', 1) assert_equal(true, condition.evaluate(Context.new)) condition.and(Condition.new(2, '==', 2)) assert_equal(true, condition.evaluate(Context.new)) condition.and(Condition.new(2, '==', 1)) assert_equal(false, condition.evaluate(Context.new)) end def test_should_allow_custom_proc_operator Condition.operators['starts_with'] = proc { |_cond, left, right| left =~ /^#{right}/ } assert_evaluates_true('bob', 'starts_with', 'b') assert_evaluates_false('bob', 'starts_with', 'o') ensure Condition.operators.delete('starts_with') end def test_left_or_right_may_contain_operators @context = Liquid::Context.new @context['one'] = @context['another'] = "gnomeslab-and-or-liquid" assert_evaluates_true(VariableLookup.new("one"), '==', VariableLookup.new("another")) end def test_default_context_is_deprecated if Gem::Version.new(Liquid::VERSION) >= Gem::Version.new('6.0.0') flunk("Condition#evaluate without a context argument is to be removed") end _out, err = capture_io do assert_equal(true, Condition.new(1, '==', 1).evaluate) end expected = "DEPRECATION WARNING: Condition#evaluate without a context argument is deprecated " \ "and will be removed from Liquid 6.0.0." assert_includes(err.lines.map(&:strip), expected) end def test_parse_expression_in_strict_mode environment = Environment.build(error_mode: :strict) parse_context = ParseContext.new(environment: environment) result = Condition.parse_expression(parse_context, 'product.title') assert_instance_of(VariableLookup, result) assert_equal('product', result.name) assert_equal(['title'], result.lookups) end def test_parse_expression_in_strict2_mode_raises_internal_error environment = Environment.build(error_mode: :strict2) parse_context = ParseContext.new(environment: environment) error = assert_raises(Liquid::InternalError) do Condition.parse_expression(parse_context, 'product.title') end assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message) end def test_parse_expression_with_safe_true_in_strict2_mode environment = Environment.build(error_mode: :strict2) parse_context = ParseContext.new(environment: environment) result = Condition.parse_expression(parse_context, 'product.title', safe: true) assert_instance_of(VariableLookup, result) assert_equal('product', result.name) assert_equal(['title'], result.lookups) end # Tests for blank? comparison without ActiveSupport # # Ruby's standard library does not include blank? on String, Array, Hash, etc. # ActiveSupport adds blank? but Liquid must work without it. These tests verify # that Liquid implements blank? semantics internally for use in templates like: # {% if x == blank %}...{% endif %} # # The blank? semantics match ActiveSupport's behavior: # - nil and false are blank # - Strings are blank if empty or contain only whitespace # - Arrays and Hashes are blank if empty # - true and numbers are never blank def test_blank_with_whitespace_string # Template authors expect " " to be blank since it has no visible content. # This matches ActiveSupport's String#blank? which returns true for whitespace-only strings. @context['whitespace'] = ' ' blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_true(VariableLookup.new('whitespace'), '==', blank_literal) end def test_blank_with_empty_string # An empty string has no content, so it should be considered blank. # This is the most basic case of a blank string. @context['empty_string'] = '' blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_true(VariableLookup.new('empty_string'), '==', blank_literal) end def test_blank_with_empty_array # Empty arrays have no elements, so they are blank. # Useful for checking if a collection has items: {% if products == blank %} @context['empty_array'] = [] blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_true(VariableLookup.new('empty_array'), '==', blank_literal) end def test_blank_with_empty_hash # Empty hashes have no key-value pairs, so they are blank. # Useful for checking if settings/options exist: {% if settings == blank %} @context['empty_hash'] = {} blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_true(VariableLookup.new('empty_hash'), '==', blank_literal) end def test_blank_with_nil # nil represents "nothing" and is the canonical blank value. # Unassigned variables resolve to nil, so this enables: {% if missing_var == blank %} @context['nil_value'] = nil blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_true(VariableLookup.new('nil_value'), '==', blank_literal) end def test_blank_with_false # false is considered blank to match ActiveSupport semantics. # This allows {% if some_flag == blank %} to work when flag is false. @context['false_value'] = false blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_true(VariableLookup.new('false_value'), '==', blank_literal) end def test_not_blank_with_true # true is a definite value, not blank. # Ensures {% if flag == blank %} works correctly for boolean flags. @context['true_value'] = true blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_false(VariableLookup.new('true_value'), '==', blank_literal) end def test_not_blank_with_number # Numbers (including zero) are never blank - they represent actual values. # 0 is a valid quantity, not the absence of a value. @context['number'] = 42 blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_false(VariableLookup.new('number'), '==', blank_literal) end def test_not_blank_with_string_content # A string with actual content is not blank. # This is the expected behavior for most template string comparisons. @context['string'] = 'hello' blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_false(VariableLookup.new('string'), '==', blank_literal) end def test_not_blank_with_non_empty_array # An array with elements has content, so it's not blank. # Enables patterns like {% unless products == blank %}Show products{% endunless %} @context['array'] = [1, 2, 3] blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_false(VariableLookup.new('array'), '==', blank_literal) end def test_not_blank_with_non_empty_hash # A hash with key-value pairs has content, so it's not blank. # Useful for checking if configuration exists: {% if config != blank %} @context['hash'] = { 'a' => 1 } blank_literal = Condition.class_variable_get(:@@method_literals)['blank'] assert_evaluates_false(VariableLookup.new('hash'), '==', blank_literal) end # Tests for empty? comparison without ActiveSupport # # empty? is distinct from blank? - it only checks if a collection has zero elements. # For strings, empty? checks length == 0, NOT whitespace content. # Ruby's standard library has empty? on String, Array, and Hash, but Liquid # provides a fallback implementation for consistency. def test_empty_with_empty_string # An empty string ("") has length 0, so it's empty. # Different from blank - empty is a stricter check. @context['empty_string'] = '' empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] assert_evaluates_true(VariableLookup.new('empty_string'), '==', empty_literal) end def test_empty_with_whitespace_string_not_empty # Whitespace strings have length > 0, so they are NOT empty. # This is the key difference between empty and blank: # " ".empty? => false, but " ".blank? => true @context['whitespace'] = ' ' empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] assert_evaluates_false(VariableLookup.new('whitespace'), '==', empty_literal) end def test_empty_with_empty_array # An array with no elements is empty. # [].empty? => true @context['empty_array'] = [] empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] assert_evaluates_true(VariableLookup.new('empty_array'), '==', empty_literal) end def test_empty_with_empty_hash # A hash with no key-value pairs is empty. # {}.empty? => true @context['empty_hash'] = {} empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] assert_evaluates_true(VariableLookup.new('empty_hash'), '==', empty_literal) end def test_nil_is_not_empty # nil is NOT empty - empty? checks if a collection has zero elements. # nil is not a collection, so it cannot be empty. # This differs from blank: nil IS blank, but nil is NOT empty. @context['nil_value'] = nil empty_literal = Condition.class_variable_get(:@@method_literals)['empty'] assert_evaluates_false(VariableLookup.new('nil_value'), '==', empty_literal) end private def assert_evaluates_true(left, op, right) assert( Condition.new(left, op, right).evaluate(@context), "Evaluated false: #{left.inspect} #{op} #{right.inspect}", ) end def assert_evaluates_false(left, op, right) assert( !Condition.new(left, op, right).evaluate(@context), "Evaluated true: #{left.inspect} #{op} #{right.inspect}", ) end def assert_evaluates_argument_error(left, op, right) assert_raises(Liquid::ArgumentError) do Condition.new(left, op, right).evaluate(@context) end end end # ConditionTest ================================================ FILE: test/unit/environment_filter_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class EnvironmentFilterTest < Minitest::Test include Liquid module AccessScopeFilters def public_filter "public" end def private_filter "private" end private :private_filter end module LateAddedFilter def late_added_filter(_input) "filtered" end end def setup @environment = Liquid::Environment.build do |env| env.register_filter(AccessScopeFilters) end @context = Context.build(environment: @environment) end def test_strainer strainer = @environment.create_strainer(@context) assert_equal(5, strainer.invoke('size', 'input')) assert_equal("public", strainer.invoke("public_filter")) end def test_strainer_raises_argument_error strainer = @environment.create_strainer(@context) assert_raises(Liquid::ArgumentError) do strainer.invoke("public_filter", 1) end end def test_strainer_argument_error_contains_backtrace strainer = @environment.create_strainer(@context) exception = assert_raises(Liquid::ArgumentError) do strainer.invoke("public_filter", 1) end assert_match( /\ALiquid error: wrong number of arguments \((1 for 0|given 1, expected 0)\)\z/, exception.message, ) source = AccessScopeFilters.instance_method(:public_filter).source_location assert_equal(source[0..1].map(&:to_s), exception.backtrace[0].split(':')[0..1]) end def test_strainer_only_invokes_public_filter_methods strainer = @environment.create_strainer(@context) assert_equal(false, strainer.class.invokable?('__test__')) assert_equal(false, strainer.class.invokable?('test')) assert_equal(false, strainer.class.invokable?('instance_eval')) assert_equal(false, strainer.class.invokable?('__send__')) assert_equal(true, strainer.class.invokable?('size')) # from the standard lib end def test_strainer_returns_nil_if_no_filter_method_found strainer = @environment.create_strainer(@context) assert_nil(strainer.invoke("private_filter")) assert_nil(strainer.invoke("undef_the_filter")) end def test_strainer_returns_first_argument_if_no_method_and_arguments_given strainer = @environment.create_strainer(@context) assert_equal("password", strainer.invoke("undef_the_method", "password")) end def test_strainer_only_allows_methods_defined_in_filters strainer = @environment.create_strainer(@context) assert_equal("1 + 1", strainer.invoke("instance_eval", "1 + 1")) assert_equal("puts", strainer.invoke("__send__", "puts", "Hi Mom")) assert_equal("has_method?", strainer.invoke("invoke", "has_method?", "invoke")) end def test_strainer_uses_a_class_cache_to_avoid_method_cache_invalidation a = Module.new b = Module.new strainer = @environment.create_strainer(@context, [a, b]) assert_kind_of(StrainerTemplate, strainer) assert_kind_of(a, strainer) assert_kind_of(b, strainer) assert_kind_of(Liquid::StandardFilters, strainer) end def test_add_global_filter_clears_cache assert_equal('input', @environment.create_strainer(@context).invoke('late_added_filter', 'input')) @environment.register_filter(LateAddedFilter) assert_equal('filtered', @environment.create_strainer(nil).invoke('late_added_filter', 'input')) end end ================================================ FILE: test/unit/environment_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class EnvironmentTest < Minitest::Test include Liquid class UnsubscribeFooter < Liquid::Tag def render(_context) 'Unsubscribe Footer' end end def test_custom_tag email_environment = Liquid::Environment.build do |environment| environment.register_tag("unsubscribe_footer", UnsubscribeFooter) end assert(email_environment.tags["unsubscribe_footer"]) assert(email_environment.tag_for_name("unsubscribe_footer")) template = Liquid::Template.parse("{% unsubscribe_footer %}", environment: email_environment) assert_equal('Unsubscribe Footer', template.render) end end ================================================ FILE: test/unit/file_system_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class FileSystemUnitTest < Minitest::Test include Liquid def test_default assert_raises(FileSystemError) do BlankFileSystem.new.read_template_file("dummy") end end def test_local file_system = Liquid::LocalFileSystem.new("/some/path") assert_equal("/some/path/_mypartial.liquid", file_system.full_path("mypartial")) assert_equal("/some/path/dir/_mypartial.liquid", file_system.full_path("dir/mypartial")) assert_raises(FileSystemError) do file_system.full_path("../dir/mypartial") end assert_raises(FileSystemError) do file_system.full_path("/dir/../../dir/mypartial") end assert_raises(FileSystemError) do file_system.full_path("/etc/passwd") end end def test_custom_template_filename_patterns file_system = Liquid::LocalFileSystem.new("/some/path", "%s.html") assert_equal("/some/path/mypartial.html", file_system.full_path("mypartial")) assert_equal("/some/path/dir/mypartial.html", file_system.full_path("dir/mypartial")) end end # FileSystemTest ================================================ FILE: test/unit/i18n_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class I18nUnitTest < Minitest::Test include Liquid def setup @i18n = I18n.new(fixture("en_locale.yml")) end def test_simple_translate_string assert_equal("less is more", @i18n.translate("simple")) end def test_nested_translate_string assert_equal("something wasn't right", @i18n.translate("errors.syntax.oops")) end def test_single_string_interpolation assert_equal("something different", @i18n.translate("whatever", something: "different")) end # def test_raises_translation_error_on_undefined_interpolation_key # assert_raises I18n::TranslationError do # @i18n.translate("whatever", :oopstypos => "yes") # end # end def test_raises_unknown_translation assert_raises(I18n::TranslationError) do @i18n.translate("doesnt_exist") end end def test_sets_default_path_to_en assert_equal(I18n::DEFAULT_LOCALE, I18n.new.path) end end ================================================ FILE: test/unit/lexer_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class LexerUnitTest < Minitest::Test include Liquid def test_strings assert_equal( [[:string, %('this is a test""')], [:string, %("wat 'lol'")], [:end_of_string]], tokenize(%( 'this is a test""' "wat 'lol'")), ) end def test_integer assert_equal( [[:id, 'hi'], [:number, '50'], [:end_of_string]], tokenize('hi 50'), ) end def test_float assert_equal( [[:id, 'hi'], [:number, '5.0'], [:end_of_string]], tokenize('hi 5.0'), ) end def test_comparison assert_equal( [[:comparison, '=='], [:comparison, '<>'], [:comparison, 'contains'], [:end_of_string]], tokenize('== <> contains '), ) end def test_comparison_without_whitespace assert_equal( [[:number, '1'], [:comparison, '>'], [:number, '0'], [:end_of_string]], tokenize('1>0'), ) end def test_comparison_with_negative_number assert_equal( [[:number, '1'], [:comparison, '>'], [:number, '-1'], [:end_of_string]], tokenize('1>-1'), ) end def test_raise_for_invalid_comparison assert_raises(SyntaxError) do tokenize('1>!1') end assert_raises(SyntaxError) do tokenize('1=<1') end assert_raises(SyntaxError) do tokenize('1!!1') end end def test_specials assert_equal( [[:pipe, '|'], [:dot, '.'], [:colon, ':'], [:end_of_string]], tokenize('| .:'), ) assert_equal( [[:open_square, '['], [:comma, ','], [:close_square, ']'], [:end_of_string]], tokenize('[,]'), ) end def test_fancy_identifiers assert_equal([[:id, 'hi'], [:id, 'five?'], [:end_of_string]], tokenize('hi five?')) assert_equal([[:number, '2'], [:id, 'foo'], [:end_of_string]], tokenize('2foo')) end def test_whitespace assert_equal( [[:id, 'five'], [:pipe, '|'], [:comparison, '=='], [:end_of_string]], tokenize("five|\n\t =="), ) end def test_unexpected_character assert_raises(SyntaxError) do tokenize("%") end end def test_negative_numbers assert_equal( [[:id, 'foo'], [:pipe, '|'], [:id, 'default'], [:colon, ":"], [:number, '-1'], [:end_of_string]], tokenize("foo | default: -1"), ) end def test_greater_than_two_digits assert_equal( [[:id, 'foo'], [:comparison, '>'], [:number, '12'], [:end_of_string]], tokenize("foo > 12"), ) end def test_error_with_utf8_character error = assert_raises(SyntaxError) do tokenize("1 < 1Ø") end assert_equal( 'Liquid syntax error: Unexpected character Ø', error.message, ) end def test_contains_as_attribute_name assert_equal( [[:id, "a"], [:dot, "."], [:id, "contains"], [:dot, "."], [:id, "b"], [:end_of_string]], tokenize("a.contains.b"), ) end def test_tokenize_incomplete_expression assert_equal([[:id, "false"], [:dash, "-"], [:end_of_string]], tokenize("false -")) assert_equal([[:id, "false"], [:comparison, "<"], [:end_of_string]], tokenize("false <")) assert_equal([[:id, "false"], [:comparison, ">"], [:end_of_string]], tokenize("false >")) assert_equal([[:id, "false"], [:number, "1"], [:end_of_string]], tokenize("false 1")) end def test_error_with_invalid_utf8 error = assert_raises(SyntaxError) do tokenize("\x00\xff") end assert_equal( 'Liquid syntax error: Invalid byte sequence in UTF-8', error.message, ) end private def tokenize(input) Lexer.tokenize(StringScanner.new(input)) end end ================================================ FILE: test/unit/parse_context_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ParseContextUnitTest < Minitest::Test include Liquid def test_safe_parse_expression_with_variable_lookup parser_strict = strict_parse_context.new_parser('product.title') result_strict = strict_parse_context.safe_parse_expression(parser_strict) parser_strict2 = strict2_parse_context.new_parser('product.title') result_strict2 = strict2_parse_context.safe_parse_expression(parser_strict2) assert_instance_of(VariableLookup, result_strict) assert_equal('product', result_strict.name) assert_equal(['title'], result_strict.lookups) assert_instance_of(VariableLookup, result_strict2) assert_equal('product', result_strict2.name) assert_equal(['title'], result_strict2.lookups) end def test_safe_parse_expression_raises_syntax_error_for_invalid_expression parser_strict = strict_parse_context.new_parser('') parser_strict2 = strict2_parse_context.new_parser('') error_strict = assert_raises(Liquid::SyntaxError) do strict_parse_context.safe_parse_expression(parser_strict) end assert_match(/is not a valid expression/, error_strict.message) error_strict2 = assert_raises(Liquid::SyntaxError) do strict2_parse_context.safe_parse_expression(parser_strict2) end assert_match(/is not a valid expression/, error_strict2.message) end def test_parse_expression_with_variable_lookup result_strict = strict_parse_context.parse_expression('product.title') assert_instance_of(VariableLookup, result_strict) assert_equal('product', result_strict.name) assert_equal(['title'], result_strict.lookups) error = assert_raises(Liquid::InternalError) do strict2_parse_context.parse_expression('product.title') end assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message) end def test_parse_expression_with_safe_true result_strict = strict_parse_context.parse_expression('product.title', safe: true) assert_instance_of(VariableLookup, result_strict) assert_equal('product', result_strict.name) assert_equal(['title'], result_strict.lookups) result_strict2 = strict2_parse_context.parse_expression('product.title', safe: true) assert_instance_of(VariableLookup, result_strict2) assert_equal('product', result_strict2.name) assert_equal(['title'], result_strict2.lookups) end def test_parse_expression_with_empty_string result_strict = strict_parse_context.parse_expression('') assert_nil(result_strict) error = assert_raises(Liquid::InternalError) do strict2_parse_context.parse_expression('') end assert_match(/unsafe parse_expression cannot be used in strict2 mode/, error.message) end def test_parse_expression_with_empty_string_and_safe_true result_strict = strict_parse_context.parse_expression('', safe: true) assert_nil(result_strict) result_strict2 = strict2_parse_context.parse_expression('', safe: true) assert_nil(result_strict2) end def test_safe_parse_expression_advances_parser_pointer parser = strict2_parse_context.new_parser('foo, bar') # safe_parse_expression consumes "foo" first_result = strict2_parse_context.safe_parse_expression(parser) assert_instance_of(VariableLookup, first_result) assert_equal('foo', first_result.name) parser.consume(:comma) # safe_parse_expression consumes "bar" second_result = strict2_parse_context.safe_parse_expression(parser) assert_instance_of(VariableLookup, second_result) assert_equal('bar', second_result.name) parser.consume(:end_of_string) end def test_parse_expression_with_whitespace_in_strict2_mode result = strict2_parse_context.parse_expression(' ', safe: true) assert_nil(result) end private def strict_parse_context @strict_parse_context ||= ParseContext.new( environment: Environment.build(error_mode: :strict), ) end def strict2_parse_context @strict2_parse_context ||= ParseContext.new( environment: Environment.build(error_mode: :strict2), ) end end ================================================ FILE: test/unit/parse_tree_visitor_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ParseTreeVisitorTest < Minitest::Test include Liquid def test_variable assert_equal( ["test"], visit(%({{ test }})), ) end def test_varible_with_filter assert_equal( ["test", "infilter"], visit(%({{ test | split: infilter }})), ) end def test_dynamic_variable assert_equal( ["test", "inlookup"], visit(%({{ test[inlookup] }})), ) end def test_echo assert_equal( ["test"], visit(%({% echo test %})), ) end def test_if_condition assert_equal( ["test"], visit(%({% if test %}{% endif %})), ) end def test_complex_if_condition assert_equal( ["test"], visit(%({% if 1 == 1 and 2 == test %}{% endif %})), ) end def test_if_body assert_equal( ["test"], visit(%({% if 1 == 1 %}{{ test }}{% endif %})), ) end def test_unless_condition assert_equal( ["test"], visit(%({% unless test %}{% endunless %})), ) end def test_complex_unless_condition assert_equal( ["test"], visit(%({% unless 1 == 1 and 2 == test %}{% endunless %})), ) end def test_unless_body assert_equal( ["test"], visit(%({% unless 1 == 1 %}{{ test }}{% endunless %})), ) end def test_elsif_condition assert_equal( ["test"], visit(%({% if 1 == 1 %}{% elsif test %}{% endif %})), ) end def test_complex_elsif_condition assert_equal( ["test"], visit(%({% if 1 == 1 %}{% elsif 1 == 1 and 2 == test %}{% endif %})), ) end def test_elsif_body assert_equal( ["test"], visit(%({% if 1 == 1 %}{% elsif 2 == 2 %}{{ test }}{% endif %})), ) end def test_else_body assert_equal( ["test"], visit(%({% if 1 == 1 %}{% else %}{{ test }}{% endif %})), ) end def test_case_left assert_equal( ["test"], visit(%({% case test %}{% endcase %})), ) end def test_case_condition assert_equal( ["test"], visit(%({% case 1 %}{% when test %}{% endcase %})), ) end def test_case_when_body assert_equal( ["test"], visit(%({% case 1 %}{% when 2 %}{{ test }}{% endcase %})), ) end def test_case_else_body assert_equal( ["test"], visit(%({% case 1 %}{% else %}{{ test }}{% endcase %})), ) end def test_for_in assert_equal( ["test"], visit(%({% for x in test %}{% endfor %})), ) end def test_for_limit assert_equal( ["test"], visit(%({% for x in (1..5) limit: test %}{% endfor %})), ) end def test_for_offset assert_equal( ["test"], visit(%({% for x in (1..5) offset: test %}{% endfor %})), ) end def test_for_body assert_equal( ["test"], visit(%({% for x in (1..5) %}{{ test }}{% endfor %})), ) end def test_for_range assert_equal( ["test"], visit(%({% for x in (1..test) %}{% endfor %})), ) end def test_tablerow_in assert_equal( ["test"], visit(%({% tablerow x in test %}{% endtablerow %})), ) end def test_tablerow_limit assert_equal( ["test"], visit(%({% tablerow x in (1..5) limit: test %}{% endtablerow %})), ) end def test_tablerow_offset assert_equal( ["test"], visit(%({% tablerow x in (1..5) offset: test %}{% endtablerow %})), ) end def test_tablerow_body assert_equal( ["test"], visit(%({% tablerow x in (1..5) %}{{ test }}{% endtablerow %})), ) end def test_cycle assert_equal( ["test"], visit(%({% cycle test %})), ) end def test_assign assert_equal( ["test"], visit(%({% assign x = test %})), ) end def test_capture assert_equal( ["test"], visit(%({% capture x %}{{ test }}{% endcapture %})), ) end def test_include assert_equal( ["test"], visit(%({% include test %})), ) end def test_include_with assert_equal( ["test"], visit(%({% include "hai" with test %})), ) end def test_include_for assert_equal( ["test"], visit(%({% include "hai" for test %})), ) end def test_render_with assert_equal( ["test"], visit(%({% render "hai" with test %})), ) end def test_render_for assert_equal( ["test"], visit(%({% render "hai" for test %})), ) end def test_preserve_tree_structure assert_equal( [[nil, [ [nil, [[nil, [["other", []]]]]], ["test", []], ["xs", []], ]]], traversal(%({% for x in xs offset: test %}{{ other }}{% endfor %})).visit, ) end private def traversal(template) ParseTreeVisitor .for(Template.parse(template).root) .add_callback_for(VariableLookup) { |node| node.name } # rubocop:disable Style/SymbolProc end def visit(template) traversal(template).visit.flatten.compact end end ================================================ FILE: test/unit/parser_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ParserUnitTest < Minitest::Test include Liquid def test_consume p = new_parser("wat: 7") assert_equal('wat', p.consume(:id)) assert_equal(':', p.consume(:colon)) assert_equal('7', p.consume(:number)) end def test_jump p = new_parser("wat: 7") p.jump(2) assert_equal('7', p.consume(:number)) end def test_consume? p = new_parser("wat: 7") assert_equal('wat', p.consume?(:id)) assert_equal(false, p.consume?(:dot)) assert_equal(':', p.consume(:colon)) assert_equal('7', p.consume?(:number)) end def test_id? p = new_parser("wat 6 Peter Hegemon") assert_equal('wat', p.id?('wat')) assert_equal(false, p.id?('endgame')) assert_equal('6', p.consume(:number)) assert_equal('Peter', p.id?('Peter')) assert_equal(false, p.id?('Achilles')) end def test_look p = new_parser("wat 6 Peter Hegemon") assert_equal(true, p.look(:id)) assert_equal('wat', p.consume(:id)) assert_equal(false, p.look(:comparison)) assert_equal(true, p.look(:number)) assert_equal(true, p.look(:id, 1)) assert_equal(false, p.look(:number, 1)) end def test_expressions p = new_parser("hi.there hi?[5].there? hi.there.bob") assert_equal('hi.there', p.expression) assert_equal('hi?[5].there?', p.expression) assert_equal('hi.there.bob', p.expression) p = new_parser("567 6.0 'lol' \"wut\"") assert_equal('567', p.expression) assert_equal('6.0', p.expression) assert_equal("'lol'", p.expression) assert_equal('"wut"', p.expression) end def test_ranges p = new_parser("(5..7) (1.5..9.6) (young..old) (hi[5].wat..old)") assert_equal('(5..7)', p.expression) assert_equal('(1.5..9.6)', p.expression) assert_equal('(young..old)', p.expression) assert_equal('(hi[5].wat..old)', p.expression) end def test_arguments p = new_parser("filter: hi.there[5], keyarg: 7") assert_equal('filter', p.consume(:id)) assert_equal(':', p.consume(:colon)) assert_equal('hi.there[5]', p.argument) assert_equal(',', p.consume(:comma)) assert_equal('keyarg: 7', p.argument) end def test_invalid_expression assert_raises(SyntaxError) do p = new_parser("==") p.expression end end private def new_parser(str) Parser.new(StringScanner.new(str)) end end ================================================ FILE: test/unit/partial_cache_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class PartialCacheUnitTest < Minitest::Test def test_uses_the_file_system_register_if_present context = Liquid::Context.build( registers: { file_system: StubFileSystem.new('my_partial' => 'my partial body'), }, ) partial = Liquid::PartialCache.load( 'my_partial', context: context, parse_context: Liquid::ParseContext.new, ) assert_equal('my partial body', partial.render) end def test_reads_from_the_file_system_only_once_per_file file_system = StubFileSystem.new('my_partial' => 'some partial body') context = Liquid::Context.build( registers: { file_system: file_system }, ) 2.times do Liquid::PartialCache.load( 'my_partial', context: context, parse_context: Liquid::ParseContext.new, ) end assert_equal(1, file_system.file_read_count) end def test_cache_state_is_stored_per_context parse_context = Liquid::ParseContext.new shared_file_system = StubFileSystem.new( 'my_partial' => 'my shared value', ) context_one = Liquid::Context.build( registers: { file_system: shared_file_system, }, ) context_two = Liquid::Context.build( registers: { file_system: shared_file_system, }, ) 2.times do Liquid::PartialCache.load( 'my_partial', context: context_one, parse_context: parse_context, ) end Liquid::PartialCache.load( 'my_partial', context: context_two, parse_context: parse_context, ) assert_equal(2, shared_file_system.file_read_count) end def test_cache_is_not_broken_when_a_different_parse_context_is_used file_system = StubFileSystem.new('my_partial' => 'some partial body') context = Liquid::Context.build( registers: { file_system: file_system }, ) Liquid::PartialCache.load( 'my_partial', context: context, parse_context: Liquid::ParseContext.new(my_key: 'value one'), ) Liquid::PartialCache.load( 'my_partial', context: context, parse_context: Liquid::ParseContext.new(my_key: 'value two'), ) # Technically what we care about is that the file was parsed twice, # but measuring file reads is an OK proxy for this. assert_equal(1, file_system.file_read_count) end def test_uses_default_template_factory_when_no_template_factory_found_in_register context = Liquid::Context.build( registers: { file_system: StubFileSystem.new('my_partial' => 'my partial body'), }, ) partial = Liquid::PartialCache.load( 'my_partial', context: context, parse_context: Liquid::ParseContext.new, ) assert_equal('my partial body', partial.render) end def test_uses_template_factory_register_if_present template_factory = StubTemplateFactory.new context = Liquid::Context.build( registers: { file_system: StubFileSystem.new('my_partial' => 'my partial body'), template_factory: template_factory, }, ) partial = Liquid::PartialCache.load( 'my_partial', context: context, parse_context: Liquid::ParseContext.new, ) assert_equal('my partial body', partial.render) assert_equal(1, template_factory.count) end def test_cache_state_is_shared_for_subcontexts parse_context = Liquid::ParseContext.new shared_file_system = StubFileSystem.new( 'my_partial' => 'my shared value', ) context = Liquid::Context.build( registers: Liquid::Registers.new( file_system: shared_file_system, ), ) subcontext = context.new_isolated_subcontext assert_equal(subcontext.registers[:cached_partials].object_id, context.registers[:cached_partials].object_id) 2.times do Liquid::PartialCache.load( 'my_partial', context: context, parse_context: parse_context, ) Liquid::PartialCache.load( 'my_partial', context: subcontext, parse_context: parse_context, ) end assert_equal(1, shared_file_system.file_read_count) end def test_uses_template_name_from_template_factory template_factory = StubTemplateFactory.new context = Liquid::Context.build( registers: { file_system: StubFileSystem.new('my_partial' => 'my partial body'), template_factory: template_factory, }, ) partial = Liquid::PartialCache.load( 'my_partial', context: context, parse_context: Liquid::ParseContext.new, ) assert_equal('some/path/my_partial', partial.name) end def test_includes_error_mode_into_template_cache template_factory = StubTemplateFactory.new context = Liquid::Context.build( registers: { file_system: StubFileSystem.new('my_partial' => 'my partial body'), template_factory: template_factory, }, ) [:lax, :warn, :strict, :strict2].each do |error_mode| Liquid::PartialCache.load( 'my_partial', context: context, parse_context: Liquid::ParseContext.new(error_mode: error_mode), ) end assert_equal( ["my_partial:lax", "my_partial:warn", "my_partial:strict", "my_partial:strict2"], context.registers[:cached_partials].keys, ) end end ================================================ FILE: test/unit/regexp_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' require 'timeout' class RegexpUnitTest < Minitest::Test include Liquid def test_empty assert_equal([], ''.scan(QuotedFragment)) end def test_quote assert_equal(['"arg 1"'], '"arg 1"'.scan(QuotedFragment)) end def test_words assert_equal(['arg1', 'arg2'], 'arg1 arg2'.scan(QuotedFragment)) end def test_tags assert_equal(['', ''], ' '.scan(QuotedFragment)) assert_equal([''], ''.scan(QuotedFragment)) assert_equal(['', ''], %().scan(QuotedFragment)) end def test_double_quoted_words assert_equal(['arg1', 'arg2', '"arg 3"'], 'arg1 arg2 "arg 3"'.scan(QuotedFragment)) end def test_single_quoted_words assert_equal(['arg1', 'arg2', "'arg 3'"], 'arg1 arg2 \'arg 3\''.scan(QuotedFragment)) end def test_quoted_words_in_the_middle assert_equal(['arg1', 'arg2', '"arg 3"', 'arg4'], 'arg1 arg2 "arg 3" arg4 '.scan(QuotedFragment)) end def test_variable_parser assert_equal(['var'], 'var'.scan(VariableParser)) assert_equal(['[var]'], '[var]'.scan(VariableParser)) assert_equal(['var', 'method'], 'var.method'.scan(VariableParser)) assert_equal(['var', '[method]'], 'var[method]'.scan(VariableParser)) assert_equal(['var', '[method]', '[0]'], 'var[method][0]'.scan(VariableParser)) assert_equal(['var', '["method"]', '[0]'], 'var["method"][0]'.scan(VariableParser)) assert_equal(['var', '[method]', '[0]', 'method'], 'var[method][0].method'.scan(VariableParser)) end def test_variable_parser_with_large_input Timeout.timeout(1) { assert_equal(['[var]'], '[var]'.scan(VariableParser)) } very_long_string = "foo" * 1000 # valid dynamic lookup Timeout.timeout(1) { assert_equal(["[#{very_long_string}]"], "[#{very_long_string}]".scan(VariableParser)) } # invalid dynamic lookup with missing closing bracket Timeout.timeout(1) { assert_equal([very_long_string], "[#{very_long_string}".scan(VariableParser)) } end end # RegexpTest ================================================ FILE: test/unit/registers_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class RegistersUnitTest < Minitest::Test include Liquid def test_set static_register = Registers.new(a: 1, b: 2) static_register[:b] = 22 static_register[:c] = 33 assert_equal(1, static_register[:a]) assert_equal(22, static_register[:b]) assert_equal(33, static_register[:c]) end def test_get_missing_key static_register = Registers.new assert_nil(static_register[:missing]) end def test_delete static_register = Registers.new(a: 1, b: 2) static_register[:b] = 22 static_register[:c] = 33 assert_nil(static_register.delete(:a)) assert_equal(22, static_register.delete(:b)) assert_equal(33, static_register.delete(:c)) assert_nil(static_register[:c]) assert_nil(static_register.delete(:d)) end def test_fetch static_register = Registers.new(a: 1, b: 2) static_register[:b] = 22 static_register[:c] = 33 assert_equal(1, static_register.fetch(:a)) assert_equal(1, static_register.fetch(:a, "default")) assert_equal(22, static_register.fetch(:b)) assert_equal(22, static_register.fetch(:b, "default")) assert_equal(33, static_register.fetch(:c)) assert_equal(33, static_register.fetch(:c, "default")) assert_raises(KeyError) do static_register.fetch(:d) end assert_equal("default", static_register.fetch(:d, "default")) result = static_register.fetch(:d) { "default" } assert_equal("default", result) result = static_register.fetch(:d, "default 1") { "default 2" } assert_equal("default 2", result) end def test_key static_register = Registers.new(a: 1, b: 2) static_register[:b] = 22 static_register[:c] = 33 assert_equal(true, static_register.key?(:a)) assert_equal(true, static_register.key?(:b)) assert_equal(true, static_register.key?(:c)) assert_equal(false, static_register.key?(:d)) end def test_static_register_can_be_frozen static_register = Registers.new(a: 1) static_register.static.freeze assert_raises(RuntimeError) do static_register.static[:a] = "foo" end assert_raises(RuntimeError) do static_register.static[:b] = "foo" end assert_raises(RuntimeError) do static_register.static.delete(:a) end assert_raises(RuntimeError) do static_register.static.delete(:c) end end def test_new_static_retains_static static_register = Registers.new(a: 1, b: 2) static_register[:b] = 22 static_register[:c] = 33 new_static_register = Registers.new(static_register) new_static_register[:b] = 222 newest_static_register = Registers.new(new_static_register) newest_static_register[:c] = 333 assert_equal(1, static_register[:a]) assert_equal(22, static_register[:b]) assert_equal(33, static_register[:c]) assert_equal(1, new_static_register[:a]) assert_equal(222, new_static_register[:b]) assert_nil(new_static_register[:c]) assert_equal(1, newest_static_register[:a]) assert_equal(2, newest_static_register[:b]) assert_equal(333, newest_static_register[:c]) end def test_multiple_instances_are_unique static_register_1 = Registers.new(a: 1, b: 2) static_register_1[:b] = 22 static_register_1[:c] = 33 static_register_2 = Registers.new(a: 10, b: 20) static_register_2[:b] = 220 static_register_2[:c] = 330 assert_equal({ a: 1, b: 2 }, static_register_1.static) assert_equal(1, static_register_1[:a]) assert_equal(22, static_register_1[:b]) assert_equal(33, static_register_1[:c]) assert_equal({ a: 10, b: 20 }, static_register_2.static) assert_equal(10, static_register_2[:a]) assert_equal(220, static_register_2[:b]) assert_equal(330, static_register_2[:c]) end def test_initialization_reused_static_same_memory_object static_register_1 = Registers.new(a: 1, b: 2) static_register_1[:b] = 22 static_register_1[:c] = 33 static_register_2 = Registers.new(static_register_1) assert_equal(1, static_register_2[:a]) assert_equal(2, static_register_2[:b]) assert_nil(static_register_2[:c]) static_register_1.static[:b] = 222 static_register_1.static[:c] = 333 assert_same(static_register_1.static, static_register_2.static) end end ================================================ FILE: test/unit/resource_limits_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ResourceLimitsUnitTest < Minitest::Test def test_cumulative_scores_initialize_to_zero limits = Liquid::ResourceLimits.new({}) assert_equal(0, limits.cumulative_render_score) assert_equal(0, limits.cumulative_assign_score) end def test_cumulative_limits_default_to_nil limits = Liquid::ResourceLimits.new({}) assert_nil(limits.cumulative_render_score_limit) assert_nil(limits.cumulative_assign_score_limit) end def test_cumulative_limits_configurable_via_hash limits = Liquid::ResourceLimits.new( cumulative_render_score_limit: 500, cumulative_assign_score_limit: 300, ) assert_equal(500, limits.cumulative_render_score_limit) assert_equal(300, limits.cumulative_assign_score_limit) end def test_cumulative_limits_configurable_via_accessor limits = Liquid::ResourceLimits.new({}) limits.cumulative_render_score_limit = 500 assert_equal(500, limits.cumulative_render_score_limit) end def test_cumulative_scores_survive_reset limits = Liquid::ResourceLimits.new({}) limits.increment_render_score(10) limits.increment_assign_score(5) limits.reset assert_equal(0, limits.render_score) assert_equal(0, limits.assign_score) assert_equal(10, limits.cumulative_render_score) assert_equal(5, limits.cumulative_assign_score) end def test_cumulative_scores_accumulate_across_resets limits = Liquid::ResourceLimits.new({}) limits.increment_render_score(10) limits.reset limits.increment_render_score(20) limits.reset limits.increment_render_score(30) assert_equal(30, limits.render_score) assert_equal(60, limits.cumulative_render_score) end def test_cumulative_render_score_limit_raises limits = Liquid::ResourceLimits.new(cumulative_render_score_limit: 25) limits.increment_render_score(10) limits.reset limits.increment_render_score(10) limits.reset assert_raises(Liquid::MemoryError) do limits.increment_render_score(10) end assert(limits.reached?) end def test_cumulative_assign_score_limit_raises limits = Liquid::ResourceLimits.new(cumulative_assign_score_limit: 15) limits.increment_assign_score(8) limits.reset assert_raises(Liquid::MemoryError) do limits.increment_assign_score(8) end assert(limits.reached?) end def test_per_template_limits_still_work_with_cumulative limits = Liquid::ResourceLimits.new( render_score_limit: 50, cumulative_render_score_limit: 1000, ) assert_raises(Liquid::MemoryError) do limits.increment_render_score(51) end end end ================================================ FILE: test/unit/strainer_template_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class StrainerTemplateUnitTest < Minitest::Test include Liquid def test_add_filter_when_wrong_filter_class c = Context.new s = c.strainer wrong_filter = lambda(&:reverse) exception = assert_raises(TypeError) do s.class.add_filter(wrong_filter) end assert_equal(exception.message, "wrong argument type Proc (expected Module)") end module PrivateMethodOverrideFilter private def public_filter "overriden as private" end end def test_add_filter_raises_when_module_privately_overrides_registered_public_methods error = assert_raises(Liquid::MethodOverrideError) do Liquid::Environment.build do |env| env.register_filter(PublicMethodOverrideFilter) env.register_filter(PrivateMethodOverrideFilter) end end assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) end module ProtectedMethodOverrideFilter protected def public_filter "overriden as protected" end end def test_add_filter_raises_when_module_overrides_registered_public_method_as_protected error = assert_raises(Liquid::MethodOverrideError) do Liquid::Environment.build do |env| env.register_filter(PublicMethodOverrideFilter) env.register_filter(ProtectedMethodOverrideFilter) end end assert_equal('Liquid error: Filter overrides registered public methods as non public: public_filter', error.message) end module PublicMethodOverrideFilter def public_filter "public" end end def test_add_filter_does_not_raise_when_module_overrides_previously_registered_method with_global_filter do context = Context.new context.add_filters([PublicMethodOverrideFilter]) strainer = context.strainer assert(strainer.class.send(:filter_methods).include?('public_filter')) end end def test_add_filter_does_not_include_already_included_module mod = Module.new do class << self attr_accessor :include_count def included(_mod) self.include_count += 1 end end self.include_count = 0 end strainer = Context.new.strainer strainer.class.add_filter(mod) strainer.class.add_filter(mod) assert_equal(1, mod.include_count) end end ================================================ FILE: test/unit/tag_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TagUnitTest < Minitest::Test include Liquid def test_tag tag = Tag.parse('tag', "", new_tokenizer, ParseContext.new) assert_equal('liquid::tag', tag.name) assert_equal('', tag.render(Context.new)) end def test_return_raw_text_of_tag tag = Tag.parse("long_tag", "param1, param2, param3", new_tokenizer, ParseContext.new) assert_equal("long_tag param1, param2, param3", tag.raw) end def test_tag_name_should_return_name_of_the_tag tag = Tag.parse("some_tag", "", new_tokenizer, ParseContext.new) assert_equal('some_tag', tag.tag_name) end class CustomTag < Liquid::Tag def render(_context); end end def test_tag_render_to_output_buffer_nil_value custom_tag = CustomTag.parse("some_tag", "", new_tokenizer, ParseContext.new) assert_equal('some string', custom_tag.render_to_output_buffer(Context.new, "some string")) end private def new_tokenizer Tokenizer.new( source: "", string_scanner: StringScanner.new(""), ) end end ================================================ FILE: test/unit/tags/case_tag_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class CaseTagUnitTest < Minitest::Test include Liquid def test_case_nodelist template = Liquid::Template.parse('{% case var %}{% when true %}WHEN{% else %}ELSE{% endcase %}') assert_equal(['WHEN', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten) end def test_case_with_trailing_element template = <<~LIQUID {%- case 1 bar -%} {%- when 1 -%} one {%- else -%} two {%- endcase -%} LIQUID with_error_modes(:lax, :strict) do assert_template_result("one", template) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Expected end_of_string but found/, error.message) end end def test_case_when_with_trailing_element template = <<~LIQUID {%- case 1 -%} {%- when 1 bar -%} one {%- else -%} two {%- endcase -%} LIQUID with_error_modes(:lax, :strict) do assert_template_result("one", template) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Expected end_of_string but found/, error.message) end end def test_case_when_with_comma template = <<~LIQUID {%- case 1 -%} {%- when 2, 1 -%} one {%- else -%} two {%- endcase -%} LIQUID with_error_modes(:lax, :strict, :strict2) do assert_template_result("one", template) end end def test_case_when_with_or template = <<~LIQUID {%- case 1 -%} {%- when 2 or 1 -%} one {%- else -%} two {%- endcase -%} LIQUID with_error_modes(:lax, :strict, :strict2) do assert_template_result("one", template) end end def test_case_when_empty template = <<~LIQUID {%- case x -%} {%- when 2 or empty -%} 2 or empty {%- else -%} not 2 or empty {%- endcase -%} LIQUID with_error_modes(:lax, :strict, :strict2) do assert_template_result("2 or empty", template, { 'x' => 2 }) assert_template_result("2 or empty", template, { 'x' => {} }) assert_template_result("2 or empty", template, { 'x' => [] }) assert_template_result("not 2 or empty", template, { 'x' => { 'a' => 'b' } }) assert_template_result("not 2 or empty", template, { 'x' => ['a'] }) assert_template_result("not 2 or empty", template, { 'x' => 4 }) end end def test_case_with_invalid_expression template = <<~LIQUID {%- case foo=>bar -%} {%- when 'baz' -%} one {%- else -%} two {%- endcase -%} LIQUID assigns = { 'foo' => { 'bar' => 'baz' } } with_error_modes(:lax, :strict) do assert_template_result("one", template, assigns) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end def test_case_when_with_invalid_expression template = <<~LIQUID {%- case 'baz' -%} {%- when foo=>bar -%} one {%- else -%} two {%- endcase -%} LIQUID assigns = { 'foo' => { 'bar' => 'baz' } } with_error_modes(:lax, :strict) do assert_template_result("one", template, assigns) end with_error_modes(:strict2) do error = assert_raises(Liquid::SyntaxError) { Template.parse(template) } assert_match(/Unexpected character =/, error.message) end end end ================================================ FILE: test/unit/tags/comment_tag_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class CommentTagUnitTest < Minitest::Test def test_comment_inside_liquid_tag assert_template_result("", <<~LIQUID.chomp) {% liquid if 1 != 1 comment else echo 123 endcomment endif %} LIQUID end def test_does_not_parse_nodes_inside_a_comment assert_template_result("", <<~LIQUID.chomp) {% comment %} {% if true %} {% if ... %} {%- for ? -%} {% while true %} {% unless if %} {% endcase %} {% endcomment %} LIQUID end def test_allows_unclosed_tags assert_template_result('', <<~LIQUID.chomp) {% comment %} {% if true %} {% endcomment %} LIQUID end def test_open_tags_in_comment assert_template_result('', <<~LIQUID.chomp) {% comment %} {% assign a = 123 {% comment %} {% endcomment %} LIQUID assert_raises(Liquid::SyntaxError) do assert_template_result("", <<~LIQUID.chomp) {% comment %} {% assign foo = "1" {% endcomment %} LIQUID end assert_raises(Liquid::SyntaxError) do assert_template_result("", <<~LIQUID.chomp) {% comment %} {% comment %} {% invalid {% endcomment %} {% endcomment %} LIQUID end assert_raises(Liquid::SyntaxError) do assert_template_result("", <<~LIQUID.chomp) {% comment %} {% {{ {%- endcomment %} LIQUID end end def test_child_comment_tags_need_to_be_closed assert_template_result("", <<~LIQUID.chomp) {% comment %} {% comment %} {% comment %}{% endcomment %} {% endcomment %} {% endcomment %} LIQUID assert_raises(Liquid::SyntaxError) do assert_template_result("", <<~LIQUID.chomp) {% comment %} {% comment %} {% comment %} {% endcomment %} {% endcomment %} LIQUID end end def test_child_raw_tags_need_to_be_closed assert_template_result("", <<~LIQUID.chomp) {% comment %} {% raw %} {% endcomment %} {% endraw %} {% endcomment %} LIQUID assert_raises(Liquid::SyntaxError) do Liquid::Template.parse(<<~LIQUID.chomp) {% comment %} {% raw %} {% endcomment %} {% endcomment %} LIQUID end end def test_error_line_number_is_correct template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) {% comment %} {% if true %} {% endcomment %} {{ errors.standard_error }} LIQUID output = template.render('errors' => ErrorDrop.new) expected = <<~TEXT.chomp Liquid error (line 4): standard error TEXT assert_equal(expected, output) end def test_comment_tag_delimiter_with_extra_strings assert_template_result( '', <<~LIQUID.chomp, {% comment %} {% comment %} {% endcomment {% if true %} {% endif %} {% endcomment %} LIQUID ) end def test_nested_comment_tag_with_extra_strings assert_template_result( '', <<~LIQUID.chomp, {% comment %} {% comment {% assign foo = 1 %} {% endcomment {% assign foo = 1 %} {% endcomment %} LIQUID ) end def test_ignores_delimiter_with_extra_strings assert_template_result( '', <<~LIQUID.chomp, {% if true %} {% comment %} {% commentXXXXX %}wut{% endcommentXXXXX %} {% endcomment %} {% endif %} LIQUID ) end def test_delimiter_can_have_extra_strings assert_template_result('', "{% comment %}123{% endcomment xyz %}") assert_template_result('', "{% comment %}123{% endcomment\txyz %}") assert_template_result('', "{% comment %}123{% endcomment\nxyz %}") assert_template_result('', "{% comment %}123{% endcomment\n xyz endcomment %}") assert_template_result('', "{%comment}{% assign a = 1 %}{%endcomment}{% endif %}") end def test_with_whitespace_control assert_template_result("Hello!", " {%- comment -%}123{%- endcomment -%}Hello!") assert_template_result("Hello!", "{%- comment -%}123{%- endcomment -%} Hello!") assert_template_result("Hello!", " {%- comment -%}123{%- endcomment -%} Hello!") assert_template_result("Hello!", <<~LIQUID.chomp) {%- comment %}Whitespace control!{% endcomment -%} Hello! LIQUID end def test_dont_override_liquid_tag_whitespace_control assert_template_result("Hello!World!", <<~LIQUID.chomp) Hello! {%- liquid comment this is inside a liquid tag endcomment -%} World! LIQUID end end ================================================ FILE: test/unit/tags/doc_tag_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class DocTagUnitTest < Minitest::Test def test_doc_tag template = <<~LIQUID.chomp {% doc %} Renders loading-spinner. @param {string} foo - some foo @param {string} [bar] - optional bar @example {% render 'loading-spinner', foo: 'foo' %} {% render 'loading-spinner', foo: 'foo', bar: 'bar' %} {% enddoc %} LIQUID assert_template_result('', template) end def test_doc_tag_body_content doc_content = " Documentation content\n @param {string} foo - test\n" template_source = "{% doc %}#{doc_content}{% enddoc %}" doc_tag = nil ParseTreeVisitor .for(Template.parse(template_source).root) .add_callback_for(Liquid::Doc) do |tag| doc_tag = tag end .visit assert_equal(doc_content, doc_tag.nodelist.first.to_s) end def test_doc_tag_does_not_support_extra_arguments error = assert_raises(Liquid::SyntaxError) do template = <<~LIQUID.chomp {% doc extra %} {% enddoc %} LIQUID Liquid::Template.parse(template) end exp_error = "Liquid syntax error: Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}" act_error = error.message assert_equal(exp_error, act_error) end def test_doc_tag_must_support_valid_tags assert_match_syntax_error("Liquid syntax error (line 1): 'doc' tag was never closed", '{% doc %} foo') assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo {% enddoc %}') assert_match_syntax_error("Liquid syntax error (line 1): Syntax Error in 'doc' - Valid syntax: {% doc %}{% enddoc %}", '{% doc } foo %}{% enddoc %}') end def test_doc_tag_ignores_liquid_nodes template = <<~LIQUID.chomp {% doc %} {% if true %} {% if ... %} {%- for ? -%} {% while true %} {% unless if %} {% endcase %} {% enddoc %} LIQUID assert_template_result('', template) end def test_doc_tag_ignores_unclosed_liquid_tags template = <<~LIQUID.chomp {% doc %} {% if true %} {% enddoc %} LIQUID assert_template_result('', template) end def test_doc_tag_does_not_allow_nested_docs error = assert_raises(Liquid::SyntaxError) do template = <<~LIQUID.chomp {% doc %} {% doc %} {% doc %} {% enddoc %} LIQUID Liquid::Template.parse(template) end exp_error = "Liquid syntax error: Syntax Error in 'doc' - Nested doc tags are not allowed" act_error = error.message assert_equal(exp_error, act_error) end def test_doc_tag_ignores_nested_raw_tags template = <<~LIQUID.chomp {% doc %} {% raw %} {% enddoc %} LIQUID assert_template_result('', template) end def test_doc_tag_ignores_unclosed_assign template = <<~LIQUID.chomp {% doc %} {% assign foo = "1" {% enddoc %} LIQUID assert_template_result('', template) end def test_doc_tag_ignores_malformed_syntax template = <<~LIQUID.chomp {% doc %} {% {{ {%- enddoc %} LIQUID assert_template_result('', template) end def test_doc_tag_captures_token_before_enddoc template_source = "{% doc %}{{ incomplete{% enddoc %}" doc_tag = nil ParseTreeVisitor .for(Template.parse(template_source).root) .add_callback_for(Liquid::Doc) do |tag| doc_tag = tag end .visit assert_equal("{{ incomplete", doc_tag.nodelist.first.to_s) end def test_doc_tag_preserves_error_line_numbers template = Liquid::Template.parse(<<~LIQUID.chomp, line_numbers: true) {% doc %} {% if true %} {% enddoc %} {{ errors.standard_error }} LIQUID expected = <<~TEXT.chomp Liquid error (line 4): standard error TEXT assert_equal(expected, template.render('errors' => ErrorDrop.new)) end def test_doc_tag_whitespace_control # Basic whitespace control assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%}Hello!") assert_template_result("Hello!", "{%- doc -%}123{%- enddoc -%} Hello!") assert_template_result("Hello!", " {%- doc -%}123{%- enddoc -%} Hello!") assert_template_result("Hello!", <<~LIQUID.chomp) {%- doc %}Whitespace control!{% enddoc -%} Hello! LIQUID end def test_doc_tag_delimiter_handling assert_template_result('', <<~LIQUID.chomp) {%- if true -%} {%- doc -%} {%- docEXTRA -%}wut{% enddocEXTRA -%}xyz {%- enddoc -%} {%- endif -%} LIQUID assert_template_result('', "{% doc %}123{% enddoc xyz %}") assert_template_result('', "{% doc %}123{% enddoc\txyz %}") assert_template_result('', "{% doc %}123{% enddoc\nxyz %}") assert_template_result('', "{% doc %}123{% enddoc\n xyz enddoc %}") end def test_doc_tag_visitor template_source = '{% doc %}{% enddoc %}' assert_equal( [Liquid::Doc], visit(template_source), ) end def test_doc_tag_blank_with_empty_content template_source = "{% doc %}{% enddoc %}" doc_tag = nil ParseTreeVisitor .for(Template.parse(template_source).root) .add_callback_for(Liquid::Doc) do |tag| doc_tag = tag end .visit assert_equal(true, doc_tag.blank?) end def test_doc_tag_blank_with_content template_source = "{% doc %}Some documentation{% enddoc %}" doc_tag = nil ParseTreeVisitor .for(Template.parse(template_source).root) .add_callback_for(Liquid::Doc) do |tag| doc_tag = tag end .visit assert_equal(false, doc_tag.blank?) end def test_doc_tag_blank_with_whitespace_only template_source = "{% doc %} {% enddoc %}" doc_tag = nil ParseTreeVisitor .for(Template.parse(template_source).root) .add_callback_for(Liquid::Doc) do |tag| doc_tag = tag end .visit assert_equal(false, doc_tag.blank?) end def test_doc_tag_nodelist_returns_array_with_body doc_content = "Documentation content\n@param {string} foo" template_source = "{% doc %}#{doc_content}{% enddoc %}" doc_tag = nil ParseTreeVisitor .for(Template.parse(template_source).root) .add_callback_for(Liquid::Doc) do |tag| doc_tag = tag end .visit assert_equal([doc_content], doc_tag.nodelist) assert_equal(1, doc_tag.nodelist.length) assert_equal(doc_content, doc_tag.nodelist.first) end def test_doc_tag_nodelist_with_empty_content template_source = "{% doc %}{% enddoc %}" doc_tag = nil ParseTreeVisitor .for(Template.parse(template_source).root) .add_callback_for(Liquid::Doc) do |tag| doc_tag = tag end .visit assert_equal([""], doc_tag.nodelist) assert_equal(1, doc_tag.nodelist.length) end private def traversal(template) ParseTreeVisitor .for(Template.parse(template).root) .add_callback_for(Liquid::Doc) do |tag| tag_class = tag.class tag_class end end def visit(template) traversal(template).visit.flatten.compact end end ================================================ FILE: test/unit/tags/for_tag_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class ForTagUnitTest < Minitest::Test def test_for_nodelist template = Liquid::Template.parse('{% for item in items %}FOR{% endfor %}') assert_equal(['FOR'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten) end def test_for_else_nodelist template = Liquid::Template.parse('{% for item in items %}FOR{% else %}ELSE{% endfor %}') assert_equal(['FOR', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten) end end ================================================ FILE: test/unit/tags/if_tag_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class IfTagUnitTest < Minitest::Test def test_if_nodelist template = Liquid::Template.parse('{% if true %}IF{% else %}ELSE{% endif %}') assert_equal(['IF', 'ELSE'], template.root.nodelist[0].nodelist.map(&:nodelist).flatten) end end ================================================ FILE: test/unit/template_factory_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TemplateFactoryUnitTest < Minitest::Test include Liquid def test_for_returns_liquid_template_instance template = TemplateFactory.new.for("anything") assert_instance_of(Liquid::Template, template) end end ================================================ FILE: test/unit/template_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TemplateUnitTest < Minitest::Test include Liquid def test_sets_default_localization_in_document t = Template.new t.parse('{%comment%}{%endcomment%}') assert_instance_of(I18n, t.root.nodelist[0].options[:locale]) end def test_sets_default_localization_in_context_with_quick_initialization t = Template.new t.parse('{%comment%}{%endcomment%}', locale: I18n.new(fixture("en_locale.yml"))) locale = t.root.nodelist[0].options[:locale] assert_instance_of(I18n, locale) assert_equal(fixture("en_locale.yml"), locale.path) end class FakeTag; end def test_tags_can_be_looped_over with_custom_tag('fake', FakeTag) do result = Template.tags.map { |name, klass| [name, klass] } assert(result.include?(["fake", TemplateUnitTest::FakeTag])) end end class TemplateSubclass < Liquid::Template end def test_template_inheritance assert_equal("foo", TemplateSubclass.parse("foo").render) end def test_invalid_utf8 input = "\xff\x00" error = assert_raises(SyntaxError) do Liquid::Tokenizer.new(source: input, string_scanner: StringScanner.new(input)) end assert_equal( 'Liquid syntax error: Invalid byte sequence in UTF-8', error.message, ) end end ================================================ FILE: test/unit/tokenizer_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class TokenizerTest < Minitest::Test def test_tokenize_strings assert_equal([' '], tokenize(' ')) assert_equal(['hello world'], tokenize('hello world')) assert_equal(['{}'], tokenize('{}')) end def test_tokenize_variables assert_equal(['{{funk}}'], tokenize('{{funk}}')) assert_equal([' ', '{{funk}}', ' '], tokenize(' {{funk}} ')) assert_equal([' ', '{{funk}}', ' ', '{{so}}', ' ', '{{brother}}', ' '], tokenize(' {{funk}} {{so}} {{brother}} ')) assert_equal([' ', '{{ funk }}', ' '], tokenize(' {{ funk }} ')) end def test_tokenize_blocks assert_equal(['{%comment%}'], tokenize('{%comment%}')) assert_equal([' ', '{%comment%}', ' '], tokenize(' {%comment%} ')) assert_equal([' ', '{%comment%}', ' ', '{%endcomment%}', ' '], tokenize(' {%comment%} {%endcomment%} ')) assert_equal([' ', '{% comment %}', ' ', '{% endcomment %}', ' '], tokenize(" {% comment %} {% endcomment %} ")) end def test_calculate_line_numbers_per_token_with_profiling assert_equal([1], tokenize_line_numbers("{{funk}}")) assert_equal([1, 1, 1], tokenize_line_numbers(" {{funk}} ")) assert_equal([1, 2, 2], tokenize_line_numbers("\n{{funk}}\n")) assert_equal([1, 1, 3], tokenize_line_numbers(" {{\n funk \n}} ")) end def test_tokenize_with_nil_source_returns_empty_array assert_equal([], tokenize(nil)) end def test_incomplete_curly_braces assert_equal(["{{.}", " "], tokenize('{{.} ')) assert_equal(["{{}", "%}"], tokenize('{{}%}')) assert_equal(["{{}}", "}"], tokenize('{{}}}')) end def test_unmatching_start_and_end assert_equal(["{{%}"], tokenize('{{%}')) assert_equal(["{{%%%}}"], tokenize('{{%%%}}')) assert_equal(["{%", "}}"], tokenize('{%}}')) assert_equal(["{%%}", "}"], tokenize('{%%}}')) end private def new_tokenizer(source, parse_context: Liquid::ParseContext.new, start_line_number: nil) parse_context.new_tokenizer(source, start_line_number: start_line_number) end def tokenize(source) tokenizer = new_tokenizer(source) tokens = [] # shift is private in Liquid::C::Tokenizer, since it is only for unit testing while (t = tokenizer.send(:shift)) tokens << t end tokens end def tokenize_line_numbers(source) tokenizer = new_tokenizer(source, start_line_number: 1) line_numbers = [] loop do line_number = tokenizer.line_number if tokenizer.send(:shift) line_numbers << line_number else break end end line_numbers end end ================================================ FILE: test/unit/variable_unit_test.rb ================================================ # frozen_string_literal: true require 'test_helper' class VariableUnitTest < Minitest::Test include Liquid def test_variable var = create_variable('hello') assert_equal(VariableLookup.new('hello'), var.name) end def test_filters var = create_variable('hello | textileze') assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['textileze', []]], var.filters) var = create_variable('hello | textileze | paragraph') assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['textileze', []], ['paragraph', []]], var.filters) var = create_variable(%( hello | strftime: '%Y')) assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['strftime', ['%Y']]], var.filters) var = create_variable(%( 'typo' | link_to: 'Typo', true )) assert_equal('typo', var.name) assert_equal([['link_to', ['Typo', true]]], var.filters) var = create_variable(%( 'typo' | link_to: 'Typo', false )) assert_equal('typo', var.name) assert_equal([['link_to', ['Typo', false]]], var.filters) var = create_variable(%( 'foo' | repeat: 3 )) assert_equal('foo', var.name) assert_equal([['repeat', [3]]], var.filters) var = create_variable(%( 'foo' | repeat: 3, 3 )) assert_equal('foo', var.name) assert_equal([['repeat', [3, 3]]], var.filters) var = create_variable(%( 'foo' | repeat: 3, 3, 3 )) assert_equal('foo', var.name) assert_equal([['repeat', [3, 3, 3]]], var.filters) var = create_variable(%( hello | strftime: '%Y, okay?')) assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['strftime', ['%Y, okay?']]], var.filters) var = create_variable(%( hello | things: "%Y, okay?", 'the other one')) assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['things', ['%Y, okay?', 'the other one']]], var.filters) end def test_filter_with_date_parameter var = create_variable(%( '2006-06-06' | date: "%m/%d/%Y")) assert_equal('2006-06-06', var.name) assert_equal([['date', ['%m/%d/%Y']]], var.filters) end def test_filters_without_whitespace var = create_variable('hello | textileze | paragraph') assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['textileze', []], ['paragraph', []]], var.filters) var = create_variable('hello|textileze|paragraph') assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['textileze', []], ['paragraph', []]], var.filters) var = create_variable("hello|replace:'foo','bar'|textileze") assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['replace', ['foo', 'bar']], ['textileze', []]], var.filters) end def test_symbol var = create_variable("http://disney.com/logo.gif | image: 'med' ", error_mode: :lax) assert_equal(VariableLookup.new('http://disney.com/logo.gif'), var.name) assert_equal([['image', ['med']]], var.filters) end def test_string_to_filter var = create_variable("'http://disney.com/logo.gif' | image: 'med' ") assert_equal('http://disney.com/logo.gif', var.name) assert_equal([['image', ['med']]], var.filters) end def test_string_single_quoted var = create_variable(%( "hello" )) assert_equal('hello', var.name) end def test_string_double_quoted var = create_variable(%( 'hello' )) assert_equal('hello', var.name) end def test_integer var = create_variable(%( 1000 )) assert_equal(1000, var.name) end def test_float var = create_variable(%( 1000.01 )) assert_equal(1000.01, var.name) end def test_dashes assert_equal(VariableLookup.new('foo-bar'), create_variable('foo-bar').name) assert_equal(VariableLookup.new('foo-bar-2'), create_variable('foo-bar-2').name) with_error_modes(:strict) do assert_raises(Liquid::SyntaxError) { create_variable('foo - bar') } assert_raises(Liquid::SyntaxError) { create_variable('-foo') } assert_raises(Liquid::SyntaxError) { create_variable('2foo') } end end def test_string_with_special_chars var = create_variable(%( 'hello! $!@.;"ddasd" ' )) assert_equal('hello! $!@.;"ddasd" ', var.name) end def test_string_dot var = create_variable(%( test.test )) assert_equal(VariableLookup.new('test.test'), var.name) end def test_filter_with_keyword_arguments var = create_variable(%( hello | things: greeting: "world", farewell: 'goodbye')) assert_equal(VariableLookup.new('hello'), var.name) assert_equal([['things', [], { 'greeting' => 'world', 'farewell' => 'goodbye' }]], var.filters) end def test_lax_filter_argument_parsing var = create_variable(%( number_of_comments | pluralize: 'comment': 'comments' ), error_mode: :lax) assert_equal(VariableLookup.new('number_of_comments'), var.name) assert_equal([['pluralize', ['comment', 'comments']]], var.filters) # missing does not throws error create_variable(%(n | f1: ,), error_mode: :lax) create_variable(%(n | f1: ,| f2), error_mode: :lax) # arg does not require colon, but ignores args :O, also ignores first kwarg since it splits on ':' var = create_variable(%(n | f1 1 | f2 k1: v1), error_mode: :lax) assert_equal([['f1', []], ['f2', [VariableLookup.new('v1')]]], var.filters) # positional and kwargs parsing var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2), error_mode: :lax) assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters) # positional and kwargs intermixed (pos1, key1: val1, pos2) var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title"), error_mode: :lax) assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters) end def test_strict_filter_argument_parsing with_error_modes(:strict) do assert_raises(SyntaxError) do create_variable(%( number_of_comments | pluralize: 'comment': 'comments' )) end end end def test_strict2_filter_argument_parsing with_error_modes(:strict2) do # optional colon var = create_variable(%(n | f1 | f2:)) assert_equal([['f1', []], ['f2', []]], var.filters) # missing argument throws error assert_raises(SyntaxError) { create_variable(%(n | f1: ,)) } assert_raises(SyntaxError) { create_variable(%(n | f1: ,| f2)) } # arg requires colon assert_raises(SyntaxError) { create_variable(%(n | f1 1)) } # trailing comma doesn't throw create_variable(%(n | f1: 1, 2, 3, | f2:)) # missing comma throws error assert_raises(SyntaxError) { create_variable(%(n | filter: 1 2, 3)) } # positional and kwargs parsing var = create_variable(%(n | filter: 1, 2, 3 | filter2: k1: 1, k2: 2)) assert_equal([['filter', [1, 2, 3]], ['filter2', [], { "k1" => 1, "k2" => 2 }]], var.filters) # positional and kwargs mixed var = create_variable(%(n | filter: 'a', 'b', key1: 1, key2: 2, 'c')) assert_equal([["filter", ["a", "b", "c"], { "key1" => 1, "key2" => 2 }]], var.filters) # positional and kwargs intermixed (pos1, key1: val1, pos2) var = create_variable(%(n | link_to: class: "black", "https://example.com", title: "title")) assert_equal([['link_to', ["https://example.com"], { "class" => "black", "title" => "title" }]], var.filters) # string key throws assert_raises(SyntaxError) { create_variable(%(n | pluralize: 'comment': 'comments')) } end end def test_output_raw_source_of_variable var = create_variable(%( name_of_variable | upcase )) assert_equal(" name_of_variable | upcase ", var.raw) end def test_variable_lookup_interface lookup = VariableLookup.new('a.b.c') assert_equal('a', lookup.name) assert_equal(['b', 'c'], lookup.lookups) end private def create_variable(markup, options = {}) Variable.new(markup, ParseContext.new(options)) end end