├── .git-blame-ignore-revs ├── .php-cs-fixer.dist.php ├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json ├── infection.json.dist ├── phpunit.xml.dist └── src └── Liquid ├── AbstractBlock.php ├── AbstractTag.php ├── Cache.php ├── Cache ├── Apc.php ├── File.php └── Local.php ├── Context.php ├── CustomFilters.php ├── Decision.php ├── Document.php ├── Drop.php ├── Exception ├── CacheException.php ├── FilesystemException.php ├── MissingFilesystemException.php ├── NotFoundException.php ├── ParseException.php ├── RenderException.php └── WrongArgumentException.php ├── FileSystem.php ├── FileSystem ├── Local.php └── Virtual.php ├── Filterbank.php ├── Liquid.php ├── LiquidException.php ├── LocalFileSystem.php ├── Regexp.php ├── StandardFilters.php ├── Tag ├── TagAssign.php ├── TagBlock.php ├── TagBreak.php ├── TagCapture.php ├── TagCase.php ├── TagComment.php ├── TagContinue.php ├── TagCycle.php ├── TagDecrement.php ├── TagExtends.php ├── TagFor.php ├── TagIf.php ├── TagIfchanged.php ├── TagInclude.php ├── TagIncrement.php ├── TagPaginate.php ├── TagRaw.php ├── TagTablerow.php └── TagUnless.php ├── Template.php └── Variable.php /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # https://docs.github.com/en/repositories/working-with-files/using-files/viewing-a-file#ignore-commits-in-the-blame-view 2 | # https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt 3 | 4 | 72c7e3f4e34d2ed474b1399f2a7ab427fb6f3b14 5 | d837112369c0193def1c27636da1b9a8df5c98f3 6 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setParallelConfig(PhpCsFixer\Runner\Parallel\ParallelConfigFactory::detect()) 24 | ->setRiskyAllowed(true) 25 | ->setRules([ 26 | '@PSR2' => true, 27 | 'psr_autoloading' => true, 28 | 'no_unreachable_default_argument_value' => true, 29 | 'no_useless_else' => true, 30 | 'no_useless_return' => true, 31 | 'phpdoc_add_missing_param_annotation' => true, 32 | 'phpdoc_order' => true, 33 | 'semicolon_after_instruction' => true, 34 | 'whitespace_after_comma_in_array' => true, 35 | 'header_comment' => ['header' => $header], 36 | 'php_unit_construct' => true, 37 | 'php_unit_dedicate_assert' => true, 38 | 'php_unit_dedicate_assert_internal_type' => true, 39 | 'php_unit_expectation' => true, 40 | 'php_unit_mock_short_will_return' => true, 41 | 'php_unit_mock' => true, 42 | 'php_unit_namespaced' => true, 43 | 'php_unit_no_expectation_annotation' => true, 44 | "phpdoc_order_by_value" => ['annotations' => ['covers']], 45 | 'php_unit_set_up_tear_down_visibility' => true, 46 | 'php_unit_test_case_static_method_calls' => ['call_type' => 'this'], 47 | 'no_whitespace_in_blank_line' => true, 48 | 'nullable_type_declaration_for_default_null_value' => true, 49 | 'array_syntax' => ['syntax' => 'short'], 50 | 'trailing_comma_in_multiline' => ['elements' => ['arrays']], 51 | 'binary_operator_spaces' => ['default' => 'at_least_single_space'], 52 | ]) 53 | ->setIndent("\t") 54 | ->setFinder( 55 | PhpCsFixer\Finder::create() 56 | ->in(__DIR__) 57 | ->append([__FILE__]) 58 | ) 59 | ; 60 | 61 | 62 | return $config; 63 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## master 2 | 3 | ## 1.4.8 (2018-03-22) 4 | 5 | * Now we return null for missing properties, like we do for missing keys for arrays. 6 | 7 | ## 1.4.7 (2018-02-09) 8 | 9 | * Paginate tag shall now respect request parameters. 10 | * It is now possible to set a custom query param for the paginate tag. 11 | * Page number will now never go overboard. 12 | 13 | ## 1.4.6 (2018-02-07) 14 | 15 | * TagPaginate shall not pollute the global scope, but work in own scope. 16 | * TagPaginate errors if no collection present instead of vague warning. 17 | 18 | ## 1.4.5 (2017-12-12) 19 | 20 | * Capture tag shall save a variable in the global context. 21 | 22 | ## 1.4.4 (2017-11-03) 23 | 24 | * TagUnless is an inverted TagIf: simplified implementation 25 | * Allow dashes in filenames 26 | 27 | ## 1.4.3 (2017-10-10) 28 | 29 | * `escape` and `escape_once` filters now escape everything, but arrays 30 | * New standard filter for explicit string conversion 31 | 32 | ## 1.4.2 (2017-10-09) 33 | 34 | * Better caching for non-extending templates 35 | * Simplified 'assign' tag to use rules for variables 36 | * Now supporting PHP 7.2 37 | * Different types of exception depending on the case 38 | * Filterbank will not call instance methods statically 39 | * Callback-type filters 40 | 41 | ## 1.4.1 (2017-09-28) 42 | 43 | * Unquoted template names in 'include' tag, as in Jekyll 44 | * Caching now works correctly with 'extends' tag 45 | 46 | ## 1.4.0 (2017-09-25) 47 | 48 | * Dropped support for EOL'ed versions of PHP (< 5.6) 49 | * Arrays won't be silently cast to string as 'Array' anymore 50 | * Complex objects could now be passed between templates and to filters 51 | * Additional test coverage 52 | 53 | ## 1.3.1 (2017-09-23) 54 | 55 | * Support for numeric and variable array indicies 56 | * Support loop break and continue 57 | * Allow looping over extended ranges 58 | * Math filters now work with floats 59 | * Fixed 'default' filter 60 | * Local cache with data stored in a private variable 61 | * Virtual file system to get inversion of control and DI 62 | * Lots of tests with the coverage upped to 97% 63 | * Small bug fixes and various enhancements 64 | 65 | ## 1.3.0 (2017-07-17) 66 | 67 | * Support Traversable loops and filters 68 | * Fix date filter for format with colon 69 | * Various minor improvements and bugs fixes 70 | 71 | ## 1.2.1 (2016-12-12) 72 | 73 | * Remove content injection from $_GET. 74 | * Add PHP 5.6, 7.0, 7.1 to Travis file. 75 | 76 | ## 1.2 (2016-06-11) 77 | 78 | * Added "ESCAPE_BY_DEFAULT" setting for context-aware auto-escaping. 79 | * Made "Context" work with plain objects. 80 | * "escape" now uses "htmlentities". 81 | * Fixed "escape_now". 82 | 83 | ## 1.1 (2015-06-01) 84 | 85 | * New tags: "paginate", "unless", "ifchanged" were added 86 | * Added support for "for in (range)" syntax 87 | * Added support for multiple conditions in if statements 88 | * Added support for hashes/objects in for loops 89 | 90 | ## 1.0 (2014-09-07) 91 | 92 | * Add namespaces 93 | * Add composer support 94 | * Implement new standard filters 95 | * Add 'raw' tag 96 | 97 | ## 0.9.2 (2012-08-15) 98 | 99 | * context->set allows now global vars 100 | * Allow Templatenames with Fileextension 101 | * Tag 'extends' supports now multiple inheritance 102 | * Clean up code, change all variables and methods to camelCase 103 | 104 | ## 0.9.1 (2012-05-12) 105 | 106 | * added the extends and block filter 107 | * Initial release 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Guz Alexander, http://guzalexander.com 2 | Copyright (c) 2011, 2012 Harald Hanek, http://www.delacap.com 3 | Copyright (c) 2006 Mateo Murphy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Liquid template engine for PHP [![CI](https://github.com/kalimatas/php-liquid/actions/workflows/tests.yaml/badge.svg)](https://github.com/kalimatas/php-liquid/actions/workflows/tests.yaml) [![Coverage Status](https://coveralls.io/repos/github/kalimatas/php-liquid/badge.svg?branch=master)](https://coveralls.io/github/kalimatas/php-liquid?branch=master) [![Total Downloads](https://poser.pugx.org/liquid/liquid/downloads.svg)](https://packagist.org/packages/liquid/liquid) 2 | 3 | Liquid is a PHP port of the [Liquid template engine for Ruby](https://github.com/Shopify/liquid), which was written by Tobias Lutke. Although there are many other templating engines for PHP, including Smarty (from which Liquid was partially inspired), Liquid had some advantages that made porting worthwhile: 4 | 5 | * Readable and human friendly syntax, that is usable in any type of document, not just html, without need for escaping. 6 | * Quick and easy to use and maintain. 7 | * 100% secure, no possibility of embedding PHP code. 8 | * Clean OO design, rather than the mix of OO and procedural found in other templating engines. 9 | * Seperate compiling and rendering stages for improved performance. 10 | * Easy to extend with your own "tags and filters":https://github.com/harrydeluxe/php-liquid/wiki/Liquid-for-programmers. 11 | * 100% Markup compatibility with a Ruby templating engine, making templates usable for either. 12 | * Unit tested: Liquid is fully unit-tested. The library is stable and ready to be used in large projects. 13 | 14 | ## Why Liquid? 15 | 16 | Why another templating library? 17 | 18 | Liquid was written to meet three templating library requirements: good performance, easy to extend, and simply to use. 19 | 20 | ## Installing 21 | 22 | You can install this lib via [composer](https://getcomposer.org/): 23 | 24 | composer require liquid/liquid 25 | 26 | ## Example template 27 | 28 | {% if products %} 29 | 42 | {% endif %} 43 | 44 | ## How to use Liquid 45 | 46 | The main class is `Liquid::Template` class. There are two separate stages of working with Liquid templates: parsing and rendering. Here is a simple example: 47 | 48 | use Liquid\Template; 49 | 50 | $template = new Template(); 51 | $template->parse("Hello, {{ name }}!"); 52 | echo $template->render(array('name' => 'Alex')); 53 | 54 | // Will echo 55 | // Hello, Alex! 56 | 57 | To find more examples have a look at the `examples` directory or at the original Ruby implementation repository's [wiki page](https://github.com/Shopify/liquid/wiki). 58 | 59 | ## Advanced usage 60 | 61 | You would probably want to add a caching layer (at very least a request-wide one), enable context-aware automatic escaping, and do load includes from disk with full file names. 62 | 63 | use Liquid\Liquid; 64 | use Liquid\Template; 65 | use Liquid\Cache\Local; 66 | 67 | Liquid::set('INCLUDE_SUFFIX', ''); 68 | Liquid::set('INCLUDE_PREFIX', ''); 69 | Liquid::set('INCLUDE_ALLOW_EXT', true); 70 | Liquid::set('ESCAPE_BY_DEFAULT', true); 71 | 72 | $template = new Template(__DIR__.'/protected/templates/'); 73 | 74 | $template->parse("Hello, {% include 'honorific.html' %}{{ plain-html | raw }} {{ comment-with-xss }}"); 75 | $template->setCache(new Local()); 76 | 77 | echo $template->render([ 78 | 'name' => 'Alex', 79 | 'plain-html' => 'Your comment was:', 80 | 'comment-with-xss' => '', 81 | ]); 82 | 83 | Will output: 84 | 85 | Hello, Mx. Alex 86 | Your comment was: <script>alert();</script> 87 | 88 | Note that automatic escaping is not a standard Liquid feature: use with care. 89 | 90 | Similarly, the following snippet will parse and render `templates/home.liquid` while storing parsing results in a class-local cache: 91 | 92 | \Liquid\Liquid::set('INCLUDE_PREFIX', ''); 93 | 94 | $template = new \Liquid\Template(__DIR__ . '/protected/templates'); 95 | $template->setCache(new \Liquid\Cache\Local()); 96 | echo $template->parseFile('home')->render(); 97 | 98 | If you render the same template over and over for at least a dozen of times, the class-local cache will give you a slight speed up in range of some milliseconds per render depending on a complexity of your template. 99 | 100 | You should probably extend `Liquid\Template` to initialize everything you do with `Liquid::set` in one place. 101 | 102 | ### Custom filters 103 | 104 | Adding filters has never been easier. 105 | 106 | $template = new Template(); 107 | $template->registerFilter('absolute_url', function ($arg) { 108 | return "https://www.example.com$arg"; 109 | }); 110 | $template->parse("{{ my_url | absolute_url }}"); 111 | echo $template->render(array( 112 | 'my_url' => '/test' 113 | )); 114 | // expect: https://www.example.com/test 115 | 116 | ## Requirements 117 | 118 | * PHP 7.4+ 119 | 120 | Some earlier versions could be used with PHP 5.3/5.4/5.5/5.6, though they're not supported anymore. 121 | 122 | ## Issues 123 | 124 | Have a bug? Please create an issue here on GitHub! 125 | 126 | [https://github.com/kalimatas/php-liquid/issues](https://github.com/kalimatas/php-liquid/issues) 127 | 128 | ## Fork notes 129 | 130 | This fork is based on [php-liquid](https://github.com/harrydeluxe/php-liquid) by Harald Hanek. 131 | 132 | It contains several improvements: 133 | 134 | * namespaces 135 | * installing via composer 136 | * new standard filters 137 | * `raw` tag added 138 | 139 | Any help is appreciated! 140 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liquid/liquid", 3 | "type": "library", 4 | "description": "Liquid template engine for PHP", 5 | "keywords": [ 6 | "liquid", 7 | "template" 8 | ], 9 | "homepage": "https://github.com/kalimatas/php-liquid", 10 | "license": "MIT", 11 | "authors": [ 12 | { 13 | "name": "Guz Alexander", 14 | "email": "kalimatas@gmail.com", 15 | "homepage": "http://guzalexander.com" 16 | }, 17 | { 18 | "name": "Harald Hanek" 19 | }, 20 | { 21 | "name": "Mateo Murphy" 22 | }, 23 | { 24 | "name": "Alexey Kopytko", 25 | "email": "alexey@kopytko.com", 26 | "homepage": "https://www.alexeykopytko.com/" 27 | } 28 | ], 29 | "require": { 30 | "php": "^7.4 || ^8.0" 31 | }, 32 | "require-dev": { 33 | "ergebnis/composer-normalize": ">=2.47", 34 | "friendsofphp/php-cs-fixer": "^3.75", 35 | "infection/infection": ">=0.17.6", 36 | "php-coveralls/php-coveralls": "^2.8", 37 | "phpunit/phpunit": "^9.6.23" 38 | }, 39 | "config": { 40 | "sort-packages": true, 41 | "allow-plugins": true 42 | }, 43 | "extra": { 44 | "branch-alias": { 45 | "dev-master": "1.x-dev" 46 | } 47 | }, 48 | "autoload": { 49 | "psr-4": { 50 | "Liquid\\": "src/Liquid" 51 | } 52 | }, 53 | "autoload-dev": { 54 | "psr-4": { 55 | "Liquid\\": "tests/Liquid" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /infection.json.dist: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 2, 3 | "source": { 4 | "directories": [ 5 | "src" 6 | ] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 14 | tests/ 15 | 16 | 17 | 18 | 19 | 20 | src/ 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /src/Liquid/AbstractBlock.php: -------------------------------------------------------------------------------- 1 | nodelist; 51 | } 52 | 53 | /** 54 | * Parses the given tokens 55 | * 56 | * @param array $tokens 57 | * 58 | * @throws \Liquid\LiquidException 59 | * @return void 60 | */ 61 | public function parse(array &$tokens) 62 | { 63 | // Constructor is not reliably called by subclasses, so we need to ensure these are set 64 | $this->startRegexp ??= new Regexp('/^' . Liquid::get('TAG_START') . '/'); 65 | $this->tagRegexp ??= new Regexp('/^' . Liquid::get('TAG_START') . Liquid::get('WHITESPACE_CONTROL') . '?\s*(\w+)\s*(.*?)' . Liquid::get('WHITESPACE_CONTROL') . '?' . Liquid::get('TAG_END') . '$/s'); 66 | $this->variableStartRegexp ??= new Regexp('/^' . Liquid::get('VARIABLE_START') . '/'); 67 | 68 | $startRegexp = $this->startRegexp; 69 | $tagRegexp = $this->tagRegexp; 70 | $variableStartRegexp = $this->variableStartRegexp; 71 | 72 | $this->nodelist = []; 73 | 74 | $tags = Template::getTags(); 75 | 76 | for ($i = 0, $n = count($tokens); $i < $n; $i++) { 77 | if ($tokens[$i] === null) { 78 | continue; 79 | } 80 | $token = $tokens[$i]; 81 | $tokens[$i] = null; 82 | 83 | if ($startRegexp->match($token)) { 84 | $this->whitespaceHandler($token); 85 | if ($tagRegexp->match($token)) { 86 | // If we found the proper block delimitor just end parsing here and let the outer block proceed 87 | if ($tagRegexp->matches[1] == $this->blockDelimiter()) { 88 | $this->endTag(); 89 | return; 90 | } 91 | 92 | $tagName = null; 93 | if (array_key_exists($tagRegexp->matches[1], $tags)) { 94 | $tagName = $tags[$tagRegexp->matches[1]]; 95 | } else { 96 | $tagName = self::TAG_PREFIX . ucwords($tagRegexp->matches[1]); 97 | $tagName = (class_exists($tagName) === true) ? $tagName : null; 98 | } 99 | 100 | if ($tagName !== null) { 101 | $this->nodelist[] = new $tagName($tagRegexp->matches[2], $tokens, $this->fileSystem); 102 | if ($tagRegexp->matches[1] == 'extends') { 103 | return; 104 | } 105 | } else { 106 | $this->unknownTag($tagRegexp->matches[1], $tagRegexp->matches[2], $tokens); 107 | } 108 | } else { 109 | throw new ParseException("Tag $token was not properly terminated (won't match $tagRegexp)"); 110 | } 111 | } elseif ($variableStartRegexp->match($token)) { 112 | $this->whitespaceHandler($token); 113 | $this->nodelist[] = $this->createVariable($token); 114 | } else { 115 | // This is neither a tag or a variable, proceed with an ltrim 116 | if (self::$trimWhitespace) { 117 | $token = ltrim($token); 118 | } 119 | 120 | self::$trimWhitespace = false; 121 | $this->nodelist[] = $token; 122 | } 123 | } 124 | 125 | $this->assertMissingDelimitation(); 126 | } 127 | 128 | /** 129 | * Handle the whitespace. 130 | * 131 | * @param string $token 132 | */ 133 | protected function whitespaceHandler($token) 134 | { 135 | $this->whitespaceControl ??= Liquid::get('WHITESPACE_CONTROL'); 136 | 137 | /* 138 | * This assumes that TAG_START is always '{%', and a whitespace control indicator 139 | * is exactly one character long, on a third position. 140 | */ 141 | if ($token[2] === $this->whitespaceControl) { 142 | $previousToken = end($this->nodelist); 143 | if (is_string($previousToken)) { // this can also be a tag or a variable 144 | $this->nodelist[key($this->nodelist)] = rtrim($previousToken); 145 | } 146 | } 147 | 148 | /* 149 | * This assumes that TAG_END is always '%}', and a whitespace control indicator 150 | * is exactly one character long, on a third position from the end. 151 | */ 152 | self::$trimWhitespace = $token[-3] === $this->whitespaceControl; 153 | } 154 | 155 | /** 156 | * Render the block. 157 | * 158 | * @param Context $context 159 | * 160 | * @return string 161 | */ 162 | public function render(Context $context) 163 | { 164 | return $this->renderAll($this->nodelist, $context); 165 | } 166 | 167 | /** 168 | * Renders all the given nodelist's nodes 169 | * 170 | * @param array $list 171 | * @param Context $context 172 | * 173 | * @return string 174 | */ 175 | protected function renderAll(array $list, Context $context) 176 | { 177 | $result = ''; 178 | 179 | foreach ($list as $token) { 180 | if (is_object($token) && method_exists($token, 'render')) { 181 | $value = $token->render($context); 182 | } else { 183 | $value = $token; 184 | } 185 | 186 | if (is_array($value)) { 187 | $value = htmlspecialchars(implode($value)); 188 | } 189 | 190 | $result .= $value; 191 | 192 | if (isset($context->registers['break'])) { 193 | break; 194 | } 195 | if (isset($context->registers['continue'])) { 196 | break; 197 | } 198 | 199 | $context->tick(); 200 | } 201 | 202 | return $result; 203 | } 204 | 205 | /** 206 | * An action to execute when the end tag is reached 207 | */ 208 | protected function endTag() 209 | { 210 | // Do nothing by default 211 | } 212 | 213 | /** 214 | * Handler for unknown tags 215 | * 216 | * @param string $tag 217 | * @param string $params 218 | * @param array $tokens 219 | * 220 | * @throws \Liquid\Exception\ParseException 221 | */ 222 | protected function unknownTag($tag, $params, array $tokens) 223 | { 224 | switch ($tag) { 225 | case 'else': 226 | throw new ParseException($this->blockName() . " does not expect else tag"); 227 | case 'end': 228 | throw new ParseException("'end' is not a valid delimiter for " . $this->blockName() . " tags. Use " . $this->blockDelimiter()); 229 | default: 230 | throw new ParseException("Unknown tag $tag"); 231 | } 232 | } 233 | 234 | /** 235 | * This method is called at the end of parsing, and will throw an error unless 236 | * this method is subclassed, like it is for Document 237 | * 238 | * @throws \Liquid\Exception\ParseException 239 | * @return bool 240 | */ 241 | protected function assertMissingDelimitation() 242 | { 243 | throw new ParseException($this->blockName() . " tag was never closed"); 244 | } 245 | 246 | /** 247 | * Returns the string that delimits the end of the block 248 | * 249 | * @return string 250 | */ 251 | protected function blockDelimiter() 252 | { 253 | return "end" . $this->blockName(); 254 | } 255 | 256 | /** 257 | * Returns the name of the block 258 | * 259 | * @return string 260 | */ 261 | private function blockName() 262 | { 263 | $reflection = new \ReflectionClass($this); 264 | return str_replace('tag', '', strtolower($reflection->getShortName())); 265 | } 266 | 267 | /** 268 | * Create a variable for the given token 269 | * 270 | * @param string $token 271 | * 272 | * @throws \Liquid\Exception\ParseException 273 | * @return Variable 274 | */ 275 | private function createVariable($token) 276 | { 277 | $this->variableRegexp ??= new Regexp('/^' . Liquid::get('VARIABLE_START') . Liquid::get('WHITESPACE_CONTROL') . '?(.*?)' . Liquid::get('WHITESPACE_CONTROL') . '?' . Liquid::get('VARIABLE_END') . '$/s'); 278 | 279 | if ($this->variableRegexp->match($token)) { 280 | return new Variable($this->variableRegexp->matches[1]); 281 | } 282 | 283 | throw new ParseException("Variable $token was not properly terminated"); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/Liquid/AbstractTag.php: -------------------------------------------------------------------------------- 1 | markup = $markup; 57 | $this->fileSystem = $fileSystem; 58 | $this->config = &Liquid::$config; 59 | 60 | $this->parse($tokens); 61 | } 62 | 63 | /** 64 | * Parse the given tokens. 65 | * 66 | * @param array $tokens 67 | */ 68 | public function parse(array &$tokens) 69 | { 70 | // Do nothing by default 71 | } 72 | 73 | /** 74 | * Render the tag with the given context. 75 | * 76 | * @param Context $context 77 | * 78 | * @return string 79 | */ 80 | abstract public function render(Context $context); 81 | 82 | /** 83 | * Extracts tag attributes from a markup string. 84 | * 85 | * @param string $markup 86 | */ 87 | protected function extractAttributes($markup) 88 | { 89 | $this->attributes = []; 90 | 91 | $attributeRegexp = new Regexp(Liquid::get('TAG_ATTRIBUTES')); 92 | 93 | $matches = $attributeRegexp->scan($markup); 94 | 95 | foreach ($matches as $match) { 96 | $this->attributes[$match[0]] = $match[1]; 97 | } 98 | } 99 | 100 | /** 101 | * Returns the name of the tag. 102 | * 103 | * @return string 104 | */ 105 | protected function name() 106 | { 107 | return strtolower(get_class($this)); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/Liquid/Cache.php: -------------------------------------------------------------------------------- 1 | expire = $options['cache_expire']; 33 | } 34 | 35 | if (isset($options['cache_prefix'])) { 36 | $this->prefix = $options['cache_prefix']; 37 | } 38 | } 39 | 40 | /** 41 | * Retrieves a value from cache with a specified key. 42 | * 43 | * @param string $key a unique key identifying the cached value 44 | * @param bool $unserialize 45 | * 46 | * @return mixed|boolean the value stored in cache, false if the value is not in the cache or expired. 47 | */ 48 | abstract public function read($key, $unserialize = true); 49 | 50 | /** 51 | * Check if specified key exists in cache. 52 | * 53 | * @param string $key a unique key identifying the cached value 54 | * 55 | * @return boolean true if the key is in cache, false otherwise 56 | */ 57 | abstract public function exists($key); 58 | 59 | /** 60 | * Stores a value identified by a key in cache. 61 | * 62 | * @param string $key the key identifying the value to be cached 63 | * @param mixed $value the value to be cached 64 | * @param bool $serialize 65 | * 66 | * @return boolean true if the value is successfully stored into cache, false otherwise 67 | */ 68 | abstract public function write($key, $value, $serialize = true); 69 | 70 | /** 71 | * Deletes all values from cache. 72 | * 73 | * @param bool $expiredOnly 74 | * 75 | * @return boolean whether the flush operation was successful. 76 | */ 77 | abstract public function flush($expiredOnly = false); 78 | } 79 | -------------------------------------------------------------------------------- /src/Liquid/Cache/Apc.php: -------------------------------------------------------------------------------- 1 | prefix . $key); 48 | } 49 | 50 | /** 51 | * {@inheritdoc} 52 | */ 53 | public function exists($key) 54 | { 55 | apc_fetch($this->prefix . $key, $success); 56 | return (bool) $success; 57 | } 58 | 59 | /** 60 | * {@inheritdoc} 61 | */ 62 | public function write($key, $value, $serialize = true) 63 | { 64 | return apc_store($this->prefix . $key, $value, $this->expire); 65 | } 66 | 67 | /** 68 | * {@inheritdoc} 69 | */ 70 | public function flush($expiredOnly = false) 71 | { 72 | return apc_clear_cache('user'); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Liquid/Cache/File.php: -------------------------------------------------------------------------------- 1 | path = realpath($options['cache_dir']) . DIRECTORY_SEPARATOR; 37 | } else { 38 | throw new NotFoundException('Cachedir not exists or not writable'); 39 | } 40 | } 41 | 42 | /** 43 | * {@inheritdoc} 44 | */ 45 | public function read($key, $unserialize = true) 46 | { 47 | if (!$this->exists($key)) { 48 | return false; 49 | } 50 | 51 | if ($unserialize) { 52 | return unserialize(file_get_contents($this->path . $this->prefix . $key)); 53 | } 54 | 55 | return file_get_contents($this->path . $this->prefix . $key); 56 | } 57 | 58 | /** 59 | * {@inheritdoc} 60 | */ 61 | public function exists($key) 62 | { 63 | $cacheFile = $this->path . $this->prefix . $key; 64 | 65 | if (!file_exists($cacheFile) || filemtime($cacheFile) + $this->expire < time()) { 66 | return false; 67 | } 68 | 69 | return true; 70 | } 71 | 72 | /** 73 | * {@inheritdoc} 74 | */ 75 | public function write($key, $value, $serialize = true) 76 | { 77 | $bytes = file_put_contents($this->path . $this->prefix . $key, $serialize ? serialize($value) : $value); 78 | $this->gc(); 79 | 80 | return $bytes !== false; 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | public function flush($expiredOnly = false) 87 | { 88 | foreach (glob($this->path . $this->prefix . '*') as $file) { 89 | if ($expiredOnly) { 90 | if (filemtime($file) + $this->expire < time()) { 91 | unlink($file); 92 | } 93 | } else { 94 | unlink($file); 95 | } 96 | } 97 | } 98 | 99 | /** 100 | * {@inheritdoc} 101 | */ 102 | protected function gc() 103 | { 104 | $this->flush(true); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Liquid/Cache/Local.php: -------------------------------------------------------------------------------- 1 | cache[$key])) { 29 | return $this->cache[$key]; 30 | } 31 | 32 | return false; 33 | } 34 | 35 | /** 36 | * {@inheritdoc} 37 | */ 38 | public function exists($key) 39 | { 40 | return isset($this->cache[$key]); 41 | } 42 | 43 | /** 44 | * {@inheritdoc} 45 | */ 46 | public function write($key, $value, $serialize = true) 47 | { 48 | $this->cache[$key] = $value; 49 | return true; 50 | } 51 | 52 | /** 53 | * {@inheritdoc} 54 | */ 55 | public function flush($expiredOnly = false) 56 | { 57 | $this->cache = []; 58 | return true; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Liquid/Context.php: -------------------------------------------------------------------------------- 1 | assigns = [$assigns]; 63 | $this->registers = $registers; 64 | $this->filterbank = new Filterbank($this); 65 | 66 | // first empty array serves as source for overrides, e.g. as in TagDecrement 67 | $this->environments = [[], []]; 68 | 69 | if (Liquid::get('EXPOSE_SERVER')) { 70 | $this->environments[1] = $_SERVER; 71 | } else { 72 | $this->environments[1] = array_filter( 73 | $_SERVER, 74 | function ($key) { 75 | return in_array( 76 | $key, 77 | (array)Liquid::get('SERVER_SUPERGLOBAL_WHITELIST') 78 | ); 79 | }, 80 | ARRAY_FILTER_USE_KEY 81 | ); 82 | } 83 | } 84 | 85 | /** 86 | * Sets a tick function, this function is called sometimes while liquid is rendering a template. 87 | * 88 | * @param callable $tickFunction 89 | */ 90 | public function setTickFunction(callable $tickFunction) 91 | { 92 | $this->tickFunction = $tickFunction; 93 | } 94 | 95 | /** 96 | * Add a filter to the context 97 | * 98 | * @param mixed $filter 99 | */ 100 | public function addFilters($filter, ?callable $callback = null) 101 | { 102 | $this->filterbank->addFilter($filter, $callback); 103 | } 104 | 105 | /** 106 | * Invoke the filter that matches given name 107 | * 108 | * @param string $name The name of the filter 109 | * @param mixed $value The value to filter 110 | * @param array $args Additional arguments for the filter 111 | * 112 | * @return string 113 | */ 114 | public function invoke($name, $value, array $args = []) 115 | { 116 | try { 117 | return $this->filterbank->invoke($name, $value, $args); 118 | } catch (\TypeError $typeError) { 119 | throw new LiquidException($typeError->getMessage(), 0, $typeError); 120 | } 121 | } 122 | 123 | /** 124 | * Merges the given assigns into the current assigns 125 | * 126 | * @param array $newAssigns 127 | */ 128 | public function merge($newAssigns) 129 | { 130 | $this->assigns[0] = array_merge($this->assigns[0], $newAssigns); 131 | } 132 | 133 | /** 134 | * Push new local scope on the stack. 135 | * 136 | * @return bool 137 | */ 138 | public function push() 139 | { 140 | array_unshift($this->assigns, []); 141 | return true; 142 | } 143 | 144 | /** 145 | * Pops the current scope from the stack. 146 | * 147 | * @throws LiquidException 148 | * @return bool 149 | */ 150 | public function pop() 151 | { 152 | if (count($this->assigns) == 1) { 153 | throw new LiquidException('No elements to pop'); 154 | } 155 | 156 | array_shift($this->assigns); 157 | } 158 | 159 | /** 160 | * Replaces [] 161 | * 162 | * @param string 163 | * @param mixed $key 164 | * 165 | * @return mixed 166 | */ 167 | public function get($key) 168 | { 169 | return $this->resolve($key); 170 | } 171 | 172 | /** 173 | * Replaces []= 174 | * 175 | * @param string $key 176 | * @param mixed $value 177 | * @param bool $global 178 | */ 179 | public function set($key, $value, $global = false) 180 | { 181 | if ($global) { 182 | for ($i = 0; $i < count($this->assigns); $i++) { 183 | $this->assigns[$i][$key] = $value; 184 | } 185 | } else { 186 | $this->assigns[0][$key] = $value; 187 | } 188 | } 189 | 190 | /** 191 | * Returns true if the given key will properly resolve 192 | * 193 | * @param string $key 194 | * 195 | * @return bool 196 | */ 197 | public function hasKey($key) 198 | { 199 | return (!is_null($this->resolve($key))); 200 | } 201 | 202 | /** 203 | * Resolve a key by either returning the appropriate literal or by looking up the appropriate variable 204 | * 205 | * Test for empty has been moved to interpret condition, in Decision 206 | * 207 | * @param string $key 208 | * 209 | * @throws LiquidException 210 | * @return mixed 211 | */ 212 | private function resolve($key) 213 | { 214 | // This shouldn't happen 215 | if (is_array($key)) { 216 | throw new LiquidException("Cannot resolve arrays as key"); 217 | } 218 | 219 | if (is_null($key) || $key == 'null') { 220 | return null; 221 | } 222 | 223 | if ($key == 'true') { 224 | return true; 225 | } 226 | 227 | if ($key == 'false') { 228 | return false; 229 | } 230 | 231 | if (preg_match('/^\'(.*)\'$/', $key, $matches)) { 232 | return $matches[1]; 233 | } 234 | 235 | if (preg_match('/^"(.*)"$/', $key, $matches)) { 236 | return $matches[1]; 237 | } 238 | 239 | if (preg_match('/^(-?\d+)$/', $key, $matches)) { 240 | return $matches[1]; 241 | } 242 | 243 | if (preg_match('/^(-?\d[\d\.]+)$/', $key, $matches)) { 244 | return $matches[1]; 245 | } 246 | 247 | return $this->variable($key); 248 | } 249 | 250 | /** 251 | * Fetches the current key in all the scopes 252 | * 253 | * @param string $key 254 | * 255 | * @return mixed 256 | */ 257 | private function fetch($key) 258 | { 259 | // TagDecrement depends on environments being checked before assigns 260 | foreach ($this->environments as $environment) { 261 | if (array_key_exists($key, $environment)) { 262 | return $environment[$key]; 263 | } 264 | } 265 | 266 | foreach ($this->assigns as $scope) { 267 | if (array_key_exists($key, $scope)) { 268 | $obj = $scope[$key]; 269 | 270 | if ($obj instanceof Drop) { 271 | $obj->setContext($this); 272 | } 273 | 274 | return $obj; 275 | } 276 | } 277 | 278 | return null; 279 | } 280 | 281 | /** 282 | * Resolved the namespaced queries gracefully. 283 | * 284 | * @param string $key 285 | * 286 | * @see Decision::stringValue 287 | * @see AbstractBlock::renderAll 288 | * 289 | * @throws LiquidException 290 | * @return mixed 291 | */ 292 | private function variable($key) 293 | { 294 | // Support numeric and variable array indicies 295 | if (preg_match("|\[[0-9]+\]|", $key)) { 296 | $key = preg_replace("|\[([0-9]+)\]|", ".$1", $key); 297 | } elseif (preg_match("|\[[0-9a-z._]+\]|", $key, $matches)) { 298 | $index = $this->get(str_replace(["[", "]"], "", $matches[0])); 299 | if (strlen($index)) { 300 | $key = preg_replace("|\[([0-9a-z._]+)\]|", ".$index", $key); 301 | } 302 | } 303 | 304 | $parts = explode(Liquid::get('VARIABLE_ATTRIBUTE_SEPARATOR'), $key); 305 | 306 | $object = $this->fetch(array_shift($parts)); 307 | 308 | while (count($parts) > 0) { 309 | // since we still have a part to consider 310 | // and since we can't dig deeper into plain values 311 | // it can be thought as if it has a property with a null value 312 | if (!is_object($object) && !is_array($object) && !is_string($object)) { 313 | return null; 314 | } 315 | 316 | // first try to cast an object to an array or value 317 | if (is_object($object)) { 318 | if (method_exists($object, 'toLiquid')) { 319 | $object = $object->toLiquid(); 320 | } elseif (method_exists($object, 'toArray')) { 321 | $object = $object->toArray(); 322 | } 323 | } 324 | 325 | if (is_null($object)) { 326 | return null; 327 | } 328 | 329 | if ($object instanceof Drop) { 330 | $object->setContext($this); 331 | } 332 | 333 | $nextPartName = array_shift($parts); 334 | 335 | if (is_string($object)) { 336 | if ($nextPartName == 'size') { 337 | // if the last part of the context variable is .size we return the string length 338 | return mb_strlen($object); 339 | } 340 | 341 | // no other special properties for strings, yet 342 | return null; 343 | } 344 | 345 | if (is_array($object)) { 346 | // if the last part of the context variable is .first we return the first array element 347 | if ($nextPartName == 'first' && count($parts) == 0 && !array_key_exists('first', $object)) { 348 | return StandardFilters::first($object); 349 | } 350 | 351 | // if the last part of the context variable is .last we return the last array element 352 | if ($nextPartName == 'last' && count($parts) == 0 && !array_key_exists('last', $object)) { 353 | return StandardFilters::last($object); 354 | } 355 | 356 | // if the last part of the context variable is .size we just return the count 357 | if ($nextPartName == 'size' && count($parts) == 0 && !array_key_exists('size', $object)) { 358 | return count($object); 359 | } 360 | 361 | // no key - no value 362 | if (!array_key_exists($nextPartName, $object)) { 363 | return null; 364 | } 365 | 366 | $object = $object[$nextPartName]; 367 | continue; 368 | } 369 | 370 | if (!is_object($object)) { 371 | // we got plain value, yet asked to resolve a part 372 | // think plain values have a null part with any name 373 | return null; 374 | } 375 | 376 | if ($object instanceof \Countable) { 377 | // if the last part of the context variable is .size we just return the count 378 | if ($nextPartName == 'size' && count($parts) == 0) { 379 | return count($object); 380 | } 381 | } 382 | 383 | if ($object instanceof Drop) { 384 | // if the object is a drop, make sure it supports the given method 385 | if (!$object->hasKey($nextPartName)) { 386 | return null; 387 | } 388 | 389 | $object = $object->invokeDrop($nextPartName); 390 | continue; 391 | } 392 | 393 | // if it has `get` or `field_exists` methods 394 | if (method_exists($object, Liquid::get('HAS_PROPERTY_METHOD'))) { 395 | if (!call_user_func([$object, Liquid::get('HAS_PROPERTY_METHOD')], $nextPartName)) { 396 | return null; 397 | } 398 | 399 | $object = call_user_func([$object, Liquid::get('GET_PROPERTY_METHOD')], $nextPartName); 400 | continue; 401 | } 402 | 403 | // if it's just a regular object, attempt to access a public method 404 | if (is_callable([$object, $nextPartName])) { 405 | $object = call_user_func([$object, $nextPartName]); 406 | continue; 407 | } 408 | 409 | // if a magic accessor method present... 410 | if (is_object($object) && method_exists($object, '__get')) { 411 | $object = $object->$nextPartName; 412 | continue; 413 | } 414 | 415 | // Inexistent property is a null, PHP-speak 416 | if (!property_exists($object, $nextPartName)) { 417 | return null; 418 | } 419 | 420 | // then try a property (independent of accessibility) 421 | if (property_exists($object, $nextPartName)) { 422 | $object = $object->$nextPartName; 423 | continue; 424 | } 425 | 426 | // we'll try casting this object in the next iteration 427 | } 428 | 429 | // lastly, try to get an embedded value of an object 430 | // value could be of any type, not just string, so we have to do this 431 | // conversion here, not later in AbstractBlock::renderAll 432 | if (is_object($object) && method_exists($object, 'toLiquid')) { 433 | $object = $object->toLiquid(); 434 | } 435 | 436 | /* 437 | * Before here were checks for object types and object to string conversion. 438 | * 439 | * Now we just return what we have: 440 | * - Traversable objects are taken care of inside filters 441 | * - Object-to-string conversion is handled at the last moment in Decision::stringValue, and in AbstractBlock::renderAll 442 | * 443 | * This way complex objects could be passed between templates and to filters 444 | */ 445 | 446 | return $object; 447 | } 448 | 449 | public function tick() 450 | { 451 | if ($this->tickFunction === null) { 452 | return; 453 | } 454 | 455 | $tickFunction = $this->tickFunction; 456 | $tickFunction($this); 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /src/Liquid/CustomFilters.php: -------------------------------------------------------------------------------- 1 | valid(); 53 | } 54 | 55 | // toLiquid is handled in Context::variable 56 | $class = get_class($value); 57 | throw new RenderException("Value of type $class has no `toLiquid` nor `__toString` methods"); 58 | } 59 | 60 | // Arrays simply return true 61 | if (is_array($value)) { 62 | return $value; 63 | } 64 | 65 | return $value; 66 | } 67 | 68 | /** 69 | * Check to see if to variables are equal in a given context 70 | * 71 | * @param string $left 72 | * @param string $right 73 | * @param Context $context 74 | * 75 | * @return bool 76 | */ 77 | protected function equalVariables($left, $right, Context $context) 78 | { 79 | $left = $this->stringValue($context->get($left)); 80 | $right = $this->stringValue($context->get($right)); 81 | 82 | return ($left == $right); 83 | } 84 | 85 | /** 86 | * Interpret a comparison 87 | * 88 | * @param string $left 89 | * @param string $right 90 | * @param string $op 91 | * @param Context $context 92 | * 93 | * @throws \Liquid\Exception\RenderException 94 | * @return bool 95 | */ 96 | protected function interpretCondition($left, $right, $op, Context $context) 97 | { 98 | if (is_null($op)) { 99 | $value = $this->stringValue($context->get($left)); 100 | return $value; 101 | } 102 | 103 | // values of 'empty' have a special meaning in array comparisons 104 | if ($right == 'empty' && is_array($context->get($left))) { 105 | $left = count($context->get($left)); 106 | $right = 0; 107 | } elseif ($left == 'empty' && is_array($context->get($right))) { 108 | $right = count($context->get($right)); 109 | $left = 0; 110 | } else { 111 | $left = $context->get($left); 112 | $right = $context->get($right); 113 | 114 | $left = $this->stringValue($left); 115 | $right = $this->stringValue($right); 116 | } 117 | 118 | // special rules for null values 119 | if (is_null($left) || is_null($right)) { 120 | // null == null returns true 121 | if ($op == '==' && is_null($left) && is_null($right)) { 122 | return true; 123 | } 124 | 125 | // null != anything other than null return true 126 | if ($op == '!=' && (!is_null($left) || !is_null($right))) { 127 | return true; 128 | } 129 | 130 | // everything else, return false; 131 | return false; 132 | } 133 | 134 | // regular rules 135 | switch ($op) { 136 | case '==': 137 | return ($left == $right); 138 | 139 | case '!=': 140 | return ($left != $right); 141 | 142 | case '>': 143 | return ($left > $right); 144 | 145 | case '<': 146 | return ($left < $right); 147 | 148 | case '>=': 149 | return ($left >= $right); 150 | 151 | case '<=': 152 | return ($left <= $right); 153 | 154 | case 'contains': 155 | return is_array($left) ? in_array($right, $left) : (strpos($left, $right) !== false); 156 | 157 | default: 158 | throw new RenderException("Error in tag '" . $this->name() . "' - Unknown operator $op"); 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Liquid/Document.php: -------------------------------------------------------------------------------- 1 | nodelist as $token) { 47 | if ($token instanceof TagExtends) { 48 | $seenExtends = true; 49 | } elseif ($token instanceof TagBlock) { 50 | $seenBlock = true; 51 | } 52 | } 53 | 54 | /* 55 | * We try to keep the base templates in cache (that not extend anything). 56 | * 57 | * At the same time if we re-render all other blocks we see, we avoid most 58 | * if not all related caching quirks. This may be suboptimal. 59 | */ 60 | if ($seenBlock && !$seenExtends) { 61 | return true; 62 | } 63 | 64 | foreach ($this->nodelist as $token) { 65 | // check any of the tokens for includes 66 | if ($token instanceof TagInclude && $token->hasIncludes()) { 67 | return true; 68 | } 69 | 70 | if ($token instanceof TagExtends && $token->hasIncludes()) { 71 | return true; 72 | } 73 | } 74 | 75 | return false; 76 | } 77 | 78 | /** 79 | * There isn't a real delimiter 80 | * 81 | * @return string 82 | */ 83 | protected function blockDelimiter() 84 | { 85 | return ''; 86 | } 87 | 88 | /** 89 | * Document blocks don't need to be terminated since they are not actually opened 90 | */ 91 | protected function assertMissingDelimitation() 92 | { 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/Liquid/Drop.php: -------------------------------------------------------------------------------- 1 | 'sales', 'limit' => 10 )); 26 | * } 27 | * } 28 | * 29 | * tmpl = Liquid::Template.parse( ' {% for product in product.top_sales %} {{ product.name }} {%endfor%} ' ) 30 | * tmpl.render('product' => ProductDrop.new ) // will invoke topSales query. 31 | * 32 | * Your drop can either implement the methods sans any parameters or implement the beforeMethod(name) method which is a 33 | * catch all. 34 | */ 35 | abstract class Drop 36 | { 37 | /** 38 | * @var Context 39 | */ 40 | protected $context; 41 | 42 | /** 43 | * Catch all method that is invoked before a specific method 44 | * 45 | * @param string $method 46 | * 47 | * @return null 48 | */ 49 | protected function beforeMethod($method) 50 | { 51 | return null; 52 | } 53 | 54 | /** 55 | * @param Context $context 56 | */ 57 | public function setContext(Context $context) 58 | { 59 | $this->context = $context; 60 | } 61 | 62 | /** 63 | * Invoke a specific method 64 | * 65 | * @param string $method 66 | * 67 | * @return mixed 68 | */ 69 | public function invokeDrop($method) 70 | { 71 | $result = $this->beforeMethod($method); 72 | 73 | if (is_null($result) && is_callable([$this, $method])) { 74 | $result = $this->$method(); 75 | } 76 | 77 | return $result; 78 | } 79 | 80 | /** 81 | * Returns true if the drop supports the given method 82 | * 83 | * @param string $name 84 | * 85 | * @return bool 86 | */ 87 | public function hasKey($name) 88 | { 89 | return true; 90 | } 91 | 92 | /** 93 | * @return Drop 94 | */ 95 | public function toLiquid() 96 | { 97 | return $this; 98 | } 99 | 100 | /** 101 | * @return string 102 | */ 103 | public function __toString() 104 | { 105 | return get_class($this); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/Liquid/Exception/CacheException.php: -------------------------------------------------------------------------------- 1 | root = $root; 53 | } 54 | 55 | /** 56 | * Retrieve a template file 57 | * 58 | * @param string $templatePath 59 | * 60 | * @return string template content 61 | */ 62 | public function readTemplateFile($templatePath) 63 | { 64 | return file_get_contents($this->fullPath($templatePath)); 65 | } 66 | 67 | /** 68 | * Resolves a given path to a full template file path, making sure it's valid 69 | * 70 | * @param string $templatePath 71 | * 72 | * @throws \Liquid\Exception\ParseException 73 | * @throws \Liquid\Exception\NotFoundException 74 | * @return string 75 | */ 76 | public function fullPath($templatePath) 77 | { 78 | if (empty($templatePath)) { 79 | throw new ParseException("Empty template name"); 80 | } 81 | 82 | $nameRegex = Liquid::get('INCLUDE_ALLOW_EXT') 83 | ? new Regexp('/^[^.\/][a-zA-Z0-9_\.\/-]+$/') 84 | : new Regexp('/^[^.\/][a-zA-Z0-9_\/-]+$/'); 85 | 86 | if (!$nameRegex->match($templatePath)) { 87 | throw new ParseException("Illegal template name '$templatePath'"); 88 | } 89 | 90 | $templateDir = dirname($templatePath); 91 | $templateFile = basename($templatePath); 92 | 93 | if (!Liquid::get('INCLUDE_ALLOW_EXT')) { 94 | $templateFile = Liquid::get('INCLUDE_PREFIX') . $templateFile . '.' . Liquid::get('INCLUDE_SUFFIX'); 95 | } 96 | 97 | $fullPath = join(DIRECTORY_SEPARATOR, [$this->root, $templateDir, $templateFile]); 98 | 99 | $realFullPath = realpath($fullPath); 100 | if ($realFullPath === false) { 101 | throw new NotFoundException("File not found: $fullPath"); 102 | } 103 | 104 | if (strpos($realFullPath, $this->root) !== 0) { 105 | throw new NotFoundException("Illegal template full path: {$realFullPath} not under {$this->root}"); 106 | } 107 | 108 | return $realFullPath; 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/Liquid/FileSystem/Virtual.php: -------------------------------------------------------------------------------- 1 | callback = $callback; 41 | } 42 | 43 | /** 44 | * Retrieve a template file 45 | * 46 | * @param string $templatePath 47 | * 48 | * @return string template content 49 | */ 50 | public function readTemplateFile($templatePath) 51 | { 52 | return call_user_func($this->callback, $templatePath); 53 | } 54 | 55 | public function __sleep() 56 | { 57 | // we cannot serialize a closure 58 | if ($this->callback instanceof \Closure) { 59 | throw new FilesystemException("Virtual file system with a Closure as a callback cannot be used with a serializing cache"); 60 | } 61 | 62 | return array_keys(get_object_vars($this)); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/Liquid/Filterbank.php: -------------------------------------------------------------------------------- 1 | context = $context; 51 | 52 | $this->addFilter(\Liquid\StandardFilters::class); 53 | $this->addFilter(\Liquid\CustomFilters::class); 54 | } 55 | 56 | /** 57 | * Adds a filter to the bank 58 | * 59 | * @param mixed $filter Can either be an object, the name of a class (in which case the 60 | * filters will be called statically) or the name of a function. 61 | * 62 | * @throws \Liquid\Exception\WrongArgumentException 63 | * @return bool 64 | */ 65 | public function addFilter($filter, ?callable $callback = null) 66 | { 67 | // If it is a callback, save it as it is 68 | if (is_string($filter) && $callback) { 69 | $this->methodMap[$filter] = $callback; 70 | return true; 71 | } 72 | 73 | // If the filter is a class, register all its static methods 74 | if (is_string($filter) && class_exists($filter)) { 75 | $reflection = new \ReflectionClass($filter); 76 | foreach ($reflection->getMethods(\ReflectionMethod::IS_STATIC) as $method) { 77 | $this->methodMap[$method->name] = $method->class; 78 | } 79 | 80 | return true; 81 | } 82 | 83 | // If it's a global function, register it simply 84 | if (is_string($filter) && function_exists($filter)) { 85 | $this->methodMap[$filter] = false; 86 | return true; 87 | } 88 | 89 | // If it isn't an object an isn't a string either, it's a bad parameter 90 | if (!is_object($filter)) { 91 | throw new WrongArgumentException("Parameter passed to addFilter must be an object or a string"); 92 | } 93 | 94 | // If the passed filter was an object, store the object for future reference. 95 | $filter->context = $this->context; 96 | $className = get_class($filter); 97 | $this->filters[$className] = $filter; 98 | 99 | // Then register all public static and not methods as filters 100 | foreach (get_class_methods($filter) as $method) { 101 | if (strtolower($method) === '__construct') { 102 | continue; 103 | } 104 | $this->methodMap[$method] = $className; 105 | } 106 | 107 | return true; 108 | } 109 | 110 | /** 111 | * Invokes the filter with the given name 112 | * 113 | * @param string $name The name of the filter 114 | * @param string $value The value to filter 115 | * @param array $args The additional arguments for the filter 116 | * 117 | * @return string 118 | */ 119 | public function invoke($name, $value, array $args = []) 120 | { 121 | // workaround for a single standard filter being a reserved keyword - we can't use overloading for static calls 122 | if ($name == 'default') { 123 | $name = '_default'; 124 | } 125 | 126 | array_unshift($args, $value); 127 | 128 | // Consult the mapping 129 | if (!isset($this->methodMap[$name])) { 130 | return $value; 131 | } 132 | 133 | $class = $this->methodMap[$name]; 134 | 135 | // If we have a callback 136 | if (is_callable($class)) { 137 | return call_user_func_array($class, $args); 138 | } 139 | 140 | // If we have a registered object for the class, use that instead 141 | if (isset($this->filters[$class])) { 142 | $class = $this->filters[$class]; 143 | } 144 | 145 | // If we're calling a function 146 | if ($class === false) { 147 | return call_user_func_array($name, $args); 148 | } 149 | 150 | // Call a class or an instance method 151 | return call_user_func_array([$class, $name], $args); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Liquid/Liquid.php: -------------------------------------------------------------------------------- 1 | 'field_exists', 29 | 30 | // This method is called on object when resolving variables when 31 | // a given property exists. 32 | 'GET_PROPERTY_METHOD' => 'get', 33 | 34 | // Separator between filters. 35 | 'FILTER_SEPARATOR' => '\|', 36 | 37 | // Separator for arguments. 38 | 'ARGUMENT_SEPARATOR' => ',', 39 | 40 | // Separator for argument names and values. 41 | 'FILTER_ARGUMENT_SEPARATOR' => ':', 42 | 43 | // Separator for variable attributes. 44 | 'VARIABLE_ATTRIBUTE_SEPARATOR' => '.', 45 | 46 | // Allow template names with extension in include and extends tags. 47 | 'INCLUDE_ALLOW_EXT' => false, 48 | 49 | // Suffix for include files. 50 | 'INCLUDE_SUFFIX' => 'liquid', 51 | 52 | // Prefix for include files. 53 | 'INCLUDE_PREFIX' => '_', 54 | 55 | // Whitespace control. 56 | 'WHITESPACE_CONTROL' => '-', 57 | 58 | // Tag start. 59 | 'TAG_START' => '{%', 60 | 61 | // Tag end. 62 | 'TAG_END' => '%}', 63 | 64 | // Variable start. 65 | 'VARIABLE_START' => '{{', 66 | 67 | // Variable end. 68 | 'VARIABLE_END' => '}}', 69 | 70 | // Variable name. 71 | 'VARIABLE_NAME' => '[a-zA-Z_][a-zA-Z_0-9.-]*', 72 | 73 | 'QUOTED_STRING' => '(?:"[^"]*"|\'[^\']*\')', 74 | 'QUOTED_STRING_FILTER_ARGUMENT' => '"[^"]*"|\'[^\']*\'', 75 | 76 | // Automatically escape any variables unless told otherwise by a "raw" filter 77 | 'ESCAPE_BY_DEFAULT' => false, 78 | 79 | // The name of the key to use when building pagination query strings e.g. ?page=1 80 | 'PAGINATION_REQUEST_KEY' => 'page', 81 | 82 | // The name of the context key used to denote the current page number 83 | 'PAGINATION_CONTEXT_KEY' => 'page', 84 | 85 | // Whenever variables from $_SERVER should be directly available to templates 86 | 'EXPOSE_SERVER' => false, 87 | 88 | // $_SERVER variables whitelist - exposed even when EXPOSE_SERVER is false 89 | 'SERVER_SUPERGLOBAL_WHITELIST' => [ 90 | 'HTTP_ACCEPT', 91 | 'HTTP_ACCEPT_CHARSET', 92 | 'HTTP_ACCEPT_ENCODING', 93 | 'HTTP_ACCEPT_LANGUAGE', 94 | 'HTTP_CONNECTION', 95 | 'HTTP_HOST', 96 | 'HTTP_REFERER', 97 | 'HTTP_USER_AGENT', 98 | 'HTTPS', 99 | 'REQUEST_METHOD', 100 | 'REQUEST_URI', 101 | 'SERVER_NAME', 102 | ], 103 | ]; 104 | 105 | /** 106 | * Get a configuration setting. 107 | * 108 | * @param string $key setting key 109 | * 110 | * @return string 111 | */ 112 | public static function get($key) 113 | { 114 | // backward compatibility 115 | if ($key === 'ALLOWED_VARIABLE_CHARS') { 116 | return substr(self::$config['VARIABLE_NAME'], 0, -1); 117 | } 118 | if (array_key_exists($key, self::$config)) { 119 | return self::$config[$key]; 120 | } 121 | // This case is needed for compound settings 122 | switch ($key) { 123 | case 'QUOTED_FRAGMENT': 124 | return '(?:' . self::get('QUOTED_STRING') . '|[^\s,\|\'"]+)'; 125 | case 'TAG_ATTRIBUTES': 126 | return '/(\w+)\s*\:\s*(' . self::get('QUOTED_FRAGMENT') . ')/'; 127 | case 'TOKENIZATION_REGEXP': 128 | return '/(' . self::$config['TAG_START'] . '.*?' . self::$config['TAG_END'] . '|' . self::$config['VARIABLE_START'] . '.*?' . self::$config['VARIABLE_END'] . ')/s'; 129 | default: 130 | return null; 131 | } 132 | } 133 | 134 | /** 135 | * Changes/creates a setting. 136 | * 137 | * @param string $key 138 | * @param string $value 139 | */ 140 | public static function set($key, $value) 141 | { 142 | // backward compatibility 143 | if ($key === 'ALLOWED_VARIABLE_CHARS') { 144 | $key = 'VARIABLE_NAME'; 145 | $value .= '+'; 146 | } 147 | self::$config[$key] = $value; 148 | } 149 | 150 | /** 151 | * Flatten a multidimensional array into a single array. Does not maintain keys. 152 | * 153 | * @param array $array 154 | * 155 | * @return array 156 | */ 157 | public static function arrayFlatten($array) 158 | { 159 | $return = []; 160 | 161 | foreach ($array as $element) { 162 | if (is_array($element)) { 163 | $return = array_merge($return, self::arrayFlatten($element)); 164 | } else { 165 | $return[] = $element; 166 | } 167 | } 168 | return $return; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Liquid/LiquidException.php: -------------------------------------------------------------------------------- 1 | pattern = (substr($pattern, 0, 1) != '/') 44 | ? '/' . $this->quote($pattern) . '/' 45 | : $pattern; 46 | } 47 | 48 | /** 49 | * Quotes regular expression characters 50 | * 51 | * @param string $string 52 | * 53 | * @return string 54 | */ 55 | public function quote($string) 56 | { 57 | return preg_quote($string, '/'); 58 | } 59 | 60 | /** 61 | * Returns an array of matches for the string in the same way as Ruby's scan method 62 | * 63 | * @param string $string 64 | * 65 | * @return array 66 | */ 67 | public function scan($string) 68 | { 69 | preg_match_all($this->pattern, $string, $matches); 70 | 71 | if (count($matches) == 1) { 72 | return $matches[0]; 73 | } 74 | 75 | array_shift($matches); 76 | 77 | $result = []; 78 | 79 | foreach ($matches as $matchKey => $subMatches) { 80 | foreach ($subMatches as $subMatchKey => $subMatch) { 81 | $result[$subMatchKey][$matchKey] = $subMatch; 82 | } 83 | } 84 | 85 | return $result; 86 | } 87 | 88 | /** 89 | * Matches the given string. Only matches once. 90 | * 91 | * @param string $string 92 | * 93 | * @return int 1 if there was a match, 0 if there wasn't 94 | */ 95 | public function match($string) 96 | { 97 | return preg_match($this->pattern, $string, $this->matches); 98 | } 99 | 100 | /** 101 | * Matches the given string. Matches all. 102 | * 103 | * @param string $string 104 | * 105 | * @return int The number of matches 106 | */ 107 | public function matchAll($string) 108 | { 109 | return preg_match_all($this->pattern, $string, $this->matches); 110 | } 111 | 112 | /** 113 | * Splits the given string 114 | * 115 | * @param string $string 116 | * @param int $limit Limits the amount of results returned 117 | * 118 | * @return array 119 | */ 120 | public function split($string, $limit = -1) 121 | { 122 | return preg_split($this->pattern, $string, $limit); 123 | } 124 | 125 | /** 126 | * Returns the original pattern primarily for debugging purposes 127 | * 128 | * @return string 129 | */ 130 | public function __toString() 131 | { 132 | return $this->pattern; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Liquid/StandardFilters.php: -------------------------------------------------------------------------------- 1 | format($dateFormat); 93 | 94 | return $formatted; 95 | } 96 | 97 | 98 | /** 99 | * Default 100 | * 101 | * @param string $input 102 | * @param string $default_value 103 | * 104 | * @return string 105 | */ 106 | public static function _default($input, $default_value) 107 | { 108 | $isBlank = $input == '' || $input === false || $input === null; 109 | return $isBlank ? $default_value : $input; 110 | } 111 | 112 | 113 | /** 114 | * division 115 | * 116 | * @param float $input 117 | * @param float $operand 118 | * 119 | * @return float 120 | */ 121 | public static function divided_by($input, $operand) 122 | { 123 | return (float)$input / (float)$operand; 124 | } 125 | 126 | 127 | /** 128 | * Convert an input to lowercase 129 | * 130 | * @param string $input 131 | * 132 | * @return string 133 | */ 134 | public static function downcase($input) 135 | { 136 | return is_string($input) ? mb_strtolower($input) : $input; 137 | } 138 | 139 | 140 | /** 141 | * Pseudo-filter: negates auto-added escape filter 142 | * 143 | * @param string $input 144 | * 145 | * @return string 146 | */ 147 | public static function raw($input) 148 | { 149 | return $input; 150 | } 151 | 152 | 153 | /** 154 | * Converts into JSON string 155 | * 156 | * @param mixed $input 157 | * 158 | * @return string 159 | */ 160 | public static function json($input) 161 | { 162 | return json_encode($input); 163 | } 164 | 165 | /** 166 | * Creates an array including only the objects with a given property value 167 | * @link https://shopify.github.io/liquid/filters/where/ 168 | * 169 | * @param mixed $input 170 | * @param string ...$args 171 | * 172 | * @throws LiquidException 173 | * @return mixed 174 | */ 175 | public static function where($input, string ...$args) 176 | { 177 | if (is_array($input)) { 178 | switch (count($args)) { 179 | case 1: 180 | return array_values(array_filter($input, fn ($v) => !in_array($v[$args[0]] ?? null, [null, false], true))); 181 | case 2: 182 | return array_values(array_filter($input, fn ($v) => ($v[$args[0]] ?? '') == $args[1])); 183 | default: 184 | throw new LiquidException('Wrong number of arguments to function `where`, given ' . count($args) . ', expected 1 or 2'); 185 | } 186 | } 187 | return $input; 188 | } 189 | 190 | /** 191 | * Escape a string 192 | * 193 | * @param string $input 194 | * 195 | * @return string 196 | */ 197 | public static function escape($input) 198 | { 199 | // Arrays are taken care down the stack with an error 200 | if (is_array($input)) { 201 | return $input; 202 | } 203 | 204 | if (is_null($input)) { 205 | return ''; 206 | } 207 | 208 | return htmlentities($input, ENT_QUOTES); 209 | } 210 | 211 | 212 | /** 213 | * Escape a string once, keeping all previous HTML entities intact 214 | * 215 | * @param string $input 216 | * 217 | * @return string 218 | */ 219 | public static function escape_once($input) 220 | { 221 | // Arrays are taken care down the stack with an error 222 | if (is_array($input)) { 223 | return $input; 224 | } 225 | 226 | return htmlentities($input, ENT_QUOTES, null, false); 227 | } 228 | 229 | 230 | /** 231 | * Returns the first element of an array 232 | * 233 | * @param array|\Iterator $input 234 | * 235 | * @return mixed 236 | */ 237 | public static function first($input) 238 | { 239 | if ($input instanceof \Iterator) { 240 | $input->rewind(); 241 | return $input->current(); 242 | } 243 | return is_array($input) ? reset($input) : $input; 244 | } 245 | 246 | 247 | /** 248 | * @param mixed $input number 249 | * 250 | * @return int 251 | */ 252 | public static function floor($input) 253 | { 254 | return (int) floor((float)$input); 255 | } 256 | 257 | 258 | /** 259 | * Joins elements of an array with a given character between them 260 | * 261 | * @param array|\Traversable $input 262 | * @param string $glue 263 | * 264 | * @return string 265 | */ 266 | public static function join($input, $glue = ' ') 267 | { 268 | if ($input instanceof \Traversable) { 269 | $str = ''; 270 | foreach ($input as $elem) { 271 | if ($str) { 272 | $str .= $glue; 273 | } 274 | $str .= $elem; 275 | } 276 | return $str; 277 | } 278 | return is_array($input) ? implode($glue, $input) : $input; 279 | } 280 | 281 | 282 | /** 283 | * Returns the last element of an array 284 | * 285 | * @param array|\Traversable $input 286 | * 287 | * @return mixed 288 | */ 289 | public static function last($input) 290 | { 291 | if ($input instanceof \Traversable) { 292 | $last = null; 293 | foreach ($input as $elem) { 294 | $last = $elem; 295 | } 296 | return $last; 297 | } 298 | return is_array($input) ? end($input) : $input; 299 | } 300 | 301 | 302 | /** 303 | * @param string $input 304 | * 305 | * @return string 306 | */ 307 | public static function lstrip($input) 308 | { 309 | return ltrim($input); 310 | } 311 | 312 | 313 | /** 314 | * Map/collect on a given property 315 | * 316 | * @param array|\Traversable $input 317 | * @param string $property 318 | * 319 | * @return string 320 | */ 321 | public static function map($input, $property) 322 | { 323 | if ($input instanceof \Traversable) { 324 | $input = iterator_to_array($input); 325 | } 326 | if (!is_array($input)) { 327 | return $input; 328 | } 329 | return array_map(function ($elem) use ($property) { 330 | if (is_callable($elem)) { 331 | return $elem(); 332 | } elseif (is_array($elem) && array_key_exists($property, $elem)) { 333 | return $elem[$property]; 334 | } 335 | return null; 336 | }, $input); 337 | } 338 | 339 | 340 | /** 341 | * subtraction 342 | * 343 | * @param float $input 344 | * @param float $operand 345 | * 346 | * @return float 347 | */ 348 | public static function minus($input, $operand) 349 | { 350 | return (float)$input - (float)$operand; 351 | } 352 | 353 | 354 | /** 355 | * modulo 356 | * 357 | * @param float $input 358 | * @param float $operand 359 | * 360 | * @return float 361 | */ 362 | public static function modulo($input, $operand) 363 | { 364 | return fmod((float)$input, (float)$operand); 365 | } 366 | 367 | 368 | /** 369 | * Replace each newline (\n) with html break 370 | * 371 | * @param string $input 372 | * 373 | * @return string 374 | */ 375 | public static function newline_to_br($input) 376 | { 377 | return is_string($input) ? str_replace("\n", "
\n", $input) : $input; 378 | } 379 | 380 | 381 | /** 382 | * addition 383 | * 384 | * @param float $input 385 | * @param float $operand 386 | * 387 | * @return float 388 | */ 389 | public static function plus($input, $operand) 390 | { 391 | return (float)$input + (float)$operand; 392 | } 393 | 394 | 395 | /** 396 | * Prepend a string to another 397 | * 398 | * @param string $input 399 | * @param string $string 400 | * 401 | * @return string 402 | */ 403 | public static function prepend($input, $string) 404 | { 405 | return $string . $input; 406 | } 407 | 408 | 409 | /** 410 | * Remove a substring 411 | * 412 | * @param string $input 413 | * @param string $string 414 | * 415 | * @return string 416 | */ 417 | public static function remove($input, $string) 418 | { 419 | return str_replace($string, '', $input); 420 | } 421 | 422 | 423 | /** 424 | * Remove the first occurrences of a substring 425 | * 426 | * @param string $input 427 | * @param string $string 428 | * 429 | * @return string 430 | */ 431 | public static function remove_first($input, $string) 432 | { 433 | if (($pos = strpos($input, $string)) !== false) { 434 | $input = substr_replace($input, '', $pos, strlen($string)); 435 | } 436 | 437 | return $input; 438 | } 439 | 440 | 441 | /** 442 | * Replace occurrences of a string with another 443 | * 444 | * @param string $input 445 | * @param string $string 446 | * @param string $replacement 447 | * 448 | * @return string 449 | */ 450 | public static function replace($input, $string, $replacement = '') 451 | { 452 | return str_replace($string, $replacement, $input); 453 | } 454 | 455 | 456 | /** 457 | * Replace the first occurrences of a string with another 458 | * 459 | * @param string $input 460 | * @param string $string 461 | * @param string $replacement 462 | * 463 | * @return string 464 | */ 465 | public static function replace_first($input, $string, $replacement = '') 466 | { 467 | if (($pos = strpos($input, $string)) !== false) { 468 | $input = substr_replace($input, $replacement, $pos, strlen($string)); 469 | } 470 | 471 | return $input; 472 | } 473 | 474 | 475 | /** 476 | * Reverse the elements of an array 477 | * 478 | * @param array|\Traversable $input 479 | * 480 | * @return array 481 | */ 482 | public static function reverse($input) 483 | { 484 | if ($input instanceof \Traversable) { 485 | $input = iterator_to_array($input); 486 | } 487 | return array_reverse($input); 488 | } 489 | 490 | 491 | /** 492 | * Round a number 493 | * 494 | * @param float $input 495 | * @param int $n precision 496 | * 497 | * @return float 498 | */ 499 | public static function round($input, $n = 0) 500 | { 501 | return round((float)$input, (int)$n); 502 | } 503 | 504 | 505 | /** 506 | * @param string $input 507 | * 508 | * @return string 509 | */ 510 | public static function rstrip($input) 511 | { 512 | return rtrim($input); 513 | } 514 | 515 | 516 | /** 517 | * Return the size of an array or of an string 518 | * 519 | * @param mixed $input 520 | * @throws RenderException 521 | * @return int 522 | */ 523 | public static function size($input) 524 | { 525 | if ($input instanceof \Iterator) { 526 | return iterator_count($input); 527 | } 528 | 529 | if (is_array($input)) { 530 | return count($input); 531 | } 532 | 533 | if (is_object($input)) { 534 | if (method_exists($input, 'size')) { 535 | return $input->size(); 536 | } 537 | 538 | if (!method_exists($input, '__toString')) { 539 | $class = get_class($input); 540 | throw new RenderException("Size of $class cannot be estimated: it has no method 'size' nor can be converted to a string"); 541 | } 542 | } 543 | 544 | // only plain values and stringable objects left at this point 545 | return strlen($input); 546 | } 547 | 548 | 549 | /** 550 | * @param array|\Iterator|string $input 551 | * @param int $offset 552 | * @param int $length 553 | * 554 | * @return array|\Iterator|string 555 | */ 556 | public static function slice($input, $offset, $length = null) 557 | { 558 | if ($input instanceof \Iterator) { 559 | $input = iterator_to_array($input); 560 | } 561 | if (is_array($input)) { 562 | $input = array_slice($input, $offset, $length); 563 | } elseif (is_string($input)) { 564 | $input = mb_substr($input, $offset, $length); 565 | } 566 | 567 | return $input; 568 | } 569 | 570 | 571 | /** 572 | * Sort the elements of an array 573 | * 574 | * @param array|\Traversable $input 575 | * @param string $property use this property of an array element 576 | * 577 | * @return array 578 | */ 579 | public static function sort($input, $property = null) 580 | { 581 | if ($input instanceof \Traversable) { 582 | $input = iterator_to_array($input); 583 | } 584 | if ($property === null) { 585 | asort($input); 586 | } else { 587 | $first = reset($input); 588 | if ($first !== false && is_array($first) && array_key_exists($property, $first)) { 589 | uasort($input, function ($a, $b) use ($property) { 590 | if (($a[$property] ?? 0) == ($b[$property] ?? 0)) { 591 | return 0; 592 | } 593 | 594 | return ($a[$property] ?? 0) < ($b[$property] ?? 0) ? -1 : 1; 595 | }); 596 | } 597 | } 598 | 599 | return $input; 600 | } 601 | 602 | /** 603 | * Explicit string conversion. 604 | * 605 | * @param mixed $input 606 | * 607 | * @return string 608 | */ 609 | public static function string($input) 610 | { 611 | return strval($input); 612 | } 613 | 614 | /** 615 | * Split input string into an array of substrings separated by given pattern. 616 | * 617 | * @param string $input 618 | * @param string $pattern 619 | * 620 | * @return array 621 | */ 622 | public static function split($input, $pattern) 623 | { 624 | if ($input === '' || $input === null) { 625 | return []; 626 | } 627 | 628 | if ($pattern === '') { 629 | return mb_str_split($input); 630 | } 631 | 632 | return explode($pattern, $input); 633 | } 634 | 635 | 636 | /** 637 | * @param string $input 638 | * 639 | * @return string 640 | */ 641 | public static function strip($input) 642 | { 643 | return trim($input); 644 | } 645 | 646 | 647 | /** 648 | * Removes html tags from text 649 | * 650 | * @param string $input 651 | * 652 | * @return string 653 | */ 654 | public static function strip_html($input) 655 | { 656 | return is_string($input) ? strip_tags($input) : $input; 657 | } 658 | 659 | 660 | /** 661 | * Strip all newlines (\n, \r) from string 662 | * 663 | * @param string $input 664 | * 665 | * @return string 666 | */ 667 | public static function strip_newlines($input) 668 | { 669 | return is_string($input) ? str_replace([ 670 | "\n", "\r", 671 | ], '', $input) : $input; 672 | } 673 | 674 | 675 | /** 676 | * multiplication 677 | * 678 | * @param float $input 679 | * @param float $operand 680 | * 681 | * @return float 682 | */ 683 | public static function times($input, $operand) 684 | { 685 | return (float)$input * (float)$operand; 686 | } 687 | 688 | 689 | /** 690 | * Truncate a string down to x characters 691 | * 692 | * @param string $input 693 | * @param int $characters 694 | * @param string $ending string to append if truncated 695 | * 696 | * @return string 697 | */ 698 | public static function truncate($input, $characters = 100, $ending = '...') 699 | { 700 | if (is_string($input) || is_numeric($input)) { 701 | if (strlen($input) > $characters) { 702 | return mb_substr($input, 0, $characters) . $ending; 703 | } 704 | } 705 | 706 | return $input; 707 | } 708 | 709 | 710 | /** 711 | * Truncate string down to x words 712 | * 713 | * @param string $input 714 | * @param int $words 715 | * @param string $ending string to append if truncated 716 | * 717 | * @return string 718 | */ 719 | public static function truncatewords($input, $words = 3, $ending = '...') 720 | { 721 | if (is_string($input)) { 722 | $wordlist = explode(" ", $input); 723 | 724 | if (count($wordlist) > $words) { 725 | return implode(" ", array_slice($wordlist, 0, $words)) . $ending; 726 | } 727 | } 728 | 729 | return $input; 730 | } 731 | 732 | 733 | /** 734 | * Remove duplicate elements from an array 735 | * 736 | * @param array|\Traversable $input 737 | * 738 | * @return array 739 | */ 740 | public static function uniq($input) 741 | { 742 | if ($input instanceof \Traversable) { 743 | $input = iterator_to_array($input); 744 | } 745 | return array_unique($input); 746 | } 747 | 748 | 749 | /** 750 | * Convert an input to uppercase 751 | * 752 | * @param string $input 753 | * 754 | * @return string 755 | */ 756 | public static function upcase($input) 757 | { 758 | return is_string($input) ? mb_strtoupper($input) : $input; 759 | } 760 | 761 | 762 | /** 763 | * URL encodes a string 764 | * 765 | * @param string $input 766 | * 767 | * @return string 768 | */ 769 | public static function url_encode($input) 770 | { 771 | return urlencode($input); 772 | } 773 | 774 | /** 775 | * Decodes a URL-encoded string 776 | * 777 | * @param string $input 778 | * 779 | * @return string 780 | */ 781 | public static function url_decode($input) 782 | { 783 | return urldecode($input); 784 | } 785 | } 786 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagAssign.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 58 | $this->to = $syntaxRegexp->matches[1]; 59 | $this->from = new Variable($syntaxRegexp->matches[2]); 60 | } else { 61 | throw new ParseException("Syntax Error in 'assign' - Valid syntax: assign [var] = [source]"); 62 | } 63 | } 64 | 65 | /** 66 | * Renders the tag 67 | * 68 | * @param Context $context 69 | * 70 | * @return string|void 71 | */ 72 | public function render(Context $context) 73 | { 74 | $output = $this->from->render($context); 75 | 76 | $context->set($this->to, $output, true); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagBlock.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 50 | $this->block = $syntaxRegexp->matches[1]; 51 | parent::__construct($markup, $tokens, $fileSystem); 52 | } else { 53 | throw new ParseException("Syntax Error in 'block' - Valid syntax: block [name]"); 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagBreak.php: -------------------------------------------------------------------------------- 1 | registers['break'] = true; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagCapture.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 50 | $this->to = $syntaxRegexp->matches[1]; 51 | parent::__construct($markup, $tokens, $fileSystem); 52 | } else { 53 | throw new ParseException("Syntax Error in 'capture' - Valid syntax: capture [var] [value]"); 54 | } 55 | } 56 | 57 | /** 58 | * Renders the block 59 | * 60 | * @param Context $context 61 | * 62 | * @return string 63 | */ 64 | public function render(Context $context) 65 | { 66 | $output = parent::render($context); 67 | 68 | $context->set($this->to, $output, true); 69 | return ''; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagCase.php: -------------------------------------------------------------------------------- 1 | nodelists = []; 70 | $this->elseNodelist = []; 71 | 72 | parent::__construct($markup, $tokens, $fileSystem); 73 | 74 | $syntaxRegexp = new Regexp('/' . Liquid::get('QUOTED_FRAGMENT') . '/'); 75 | 76 | if ($syntaxRegexp->match($markup)) { 77 | $this->left = $syntaxRegexp->matches[0]; 78 | } else { 79 | throw new ParseException("Syntax Error in tag 'case' - Valid syntax: case [condition]"); // harry 80 | } 81 | } 82 | 83 | /** 84 | * Pushes the last nodelist onto the stack 85 | */ 86 | public function endTag() 87 | { 88 | $this->pushNodelist(); 89 | } 90 | 91 | /** 92 | * Unknown tag handler 93 | * 94 | * @param string $tag 95 | * @param string $params 96 | * @param array $tokens 97 | * 98 | * @throws \Liquid\Exception\ParseException 99 | */ 100 | public function unknownTag($tag, $params, array $tokens) 101 | { 102 | switch ($tag) { 103 | case 'when': 104 | $whenSyntax = preg_match_all('/(?<=,|or|^)\s*(' . Liquid::get('QUOTED_FRAGMENT') . ')/', $params, $matches); 105 | // push the current nodelist onto the stack and prepare for a new one 106 | if ($whenSyntax) { 107 | $this->pushNodelist(); 108 | $this->right = $matches[1]; 109 | $this->nodelist = []; 110 | } else { 111 | throw new ParseException("Syntax Error in tag 'case' - Valid when condition: when [condition]"); // harry 112 | } 113 | break; 114 | 115 | case 'else': 116 | // push the last nodelist onto the stack and prepare to receive the else nodes 117 | $this->pushNodelist(); 118 | $this->right = null; 119 | $this->elseNodelist = &$this->nodelist; 120 | $this->nodelist = []; 121 | break; 122 | 123 | default: 124 | parent::unknownTag($tag, $params, $tokens); 125 | } 126 | } 127 | 128 | /** 129 | * Pushes the current right value and nodelist into the nodelist stack 130 | */ 131 | public function pushNodelist() 132 | { 133 | if (!is_null($this->right)) { 134 | $this->nodelists[] = [$this->right, $this->nodelist]; 135 | } 136 | } 137 | 138 | /** 139 | * Renders the node 140 | * 141 | * @param Context $context 142 | * 143 | * @return string 144 | */ 145 | public function render(Context $context) 146 | { 147 | $output = ''; // array(); 148 | $runElseBlock = true; 149 | 150 | foreach ($this->nodelists as $data) { 151 | list($right, $nodelist) = $data; 152 | 153 | foreach ($right as $var) { 154 | if ($this->equalVariables($this->left, $var, $context)) { 155 | $runElseBlock = false; 156 | 157 | $context->push(); 158 | $output .= $this->renderAll($nodelist, $context); 159 | $context->pop(); 160 | 161 | break; 162 | } 163 | } 164 | } 165 | 166 | if ($runElseBlock) { 167 | $context->push(); 168 | $output .= $this->renderAll($this->elseNodelist, $context); 169 | $context->pop(); 170 | } 171 | 172 | return $output; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagComment.php: -------------------------------------------------------------------------------- 1 | registers['continue'] = true; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagCycle.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 66 | $this->variables = $this->variablesFromString($namedSyntax->matches[2]); 67 | $this->name = $namedSyntax->matches[1]; 68 | } elseif ($simpleSyntax->match($markup)) { 69 | $this->variables = $this->variablesFromString($markup); 70 | $this->name = "'" . implode($this->variables) . "'"; 71 | } else { 72 | throw new ParseException("Syntax Error in 'cycle' - Valid syntax: cycle [name :] var [, var2, var3 ...]"); 73 | } 74 | } 75 | 76 | /** 77 | * Renders the tag 78 | * 79 | * @var Context $context 80 | * @return string 81 | */ 82 | public function render(Context $context) 83 | { 84 | $context->push(); 85 | 86 | $key = $context->get($this->name); 87 | 88 | if (isset($context->registers['cycle'][$key])) { 89 | $iteration = $context->registers['cycle'][$key]; 90 | } else { 91 | $iteration = 0; 92 | } 93 | 94 | $result = $context->get($this->variables[$iteration]); 95 | 96 | $iteration += 1; 97 | 98 | if ($iteration >= count($this->variables)) { 99 | $iteration = 0; 100 | } 101 | 102 | $context->registers['cycle'][$key] = $iteration; 103 | 104 | $context->pop(); 105 | 106 | return $result; 107 | } 108 | 109 | /** 110 | * Extract variables from a string of markup 111 | * 112 | * @param string $markup 113 | * 114 | * @return array; 115 | */ 116 | private function variablesFromString($markup) 117 | { 118 | $regexp = new Regexp('/\s*(' . Liquid::get('QUOTED_FRAGMENT') . ')\s*/'); 119 | $parts = explode(',', $markup); 120 | $result = []; 121 | 122 | foreach ($parts as $part) { 123 | $regexp->match($part); 124 | 125 | if (!empty($regexp->matches[1])) { 126 | $result[] = $regexp->matches[1]; 127 | } 128 | } 129 | 130 | return $result; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagDecrement.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 55 | $this->toDecrement = $syntax->matches[0]; 56 | } else { 57 | throw new ParseException("Syntax Error in 'decrement' - Valid syntax: decrement [var]"); 58 | } 59 | } 60 | 61 | /** 62 | * Renders the tag 63 | * 64 | * @param Context $context 65 | * 66 | * @return string|void 67 | */ 68 | public function render(Context $context) 69 | { 70 | // if the value is not set in the environment check to see if it 71 | // exists in the context, and if not set it to 0 72 | if (!isset($context->environments[0][$this->toDecrement])) { 73 | // check for a context value 74 | $fromContext = $context->get($this->toDecrement); 75 | 76 | // we already have a value in the context 77 | $context->environments[0][$this->toDecrement] = (null !== $fromContext) ? $fromContext : 0; 78 | } 79 | 80 | // decrement the environment value 81 | $context->environments[0][$this->toDecrement]--; 82 | 83 | return ''; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagExtends.php: -------------------------------------------------------------------------------- 1 | match($markup) && isset($regex->matches[1])) { 62 | $this->templateName = substr($regex->matches[1], 1, strlen($regex->matches[1]) - 2); 63 | } else { 64 | throw new ParseException("Error in tag 'extends' - Valid syntax: extends '[template name]'"); 65 | } 66 | 67 | parent::__construct($markup, $tokens, $fileSystem); 68 | } 69 | 70 | /** 71 | * @param array $tokens 72 | * 73 | * @return array 74 | */ 75 | private function findBlocks(array $tokens) 76 | { 77 | $blockstartRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*block (\w+)\s*(.*)?' . Liquid::get('TAG_END') . '$/'); 78 | $blockendRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*endblock\s*?' . Liquid::get('TAG_END') . '$/'); 79 | 80 | $b = []; 81 | $name = null; 82 | 83 | for ($i = 0, $n = count($tokens); $i < $n; $i++) { 84 | if ($tokens[$i] === null) { 85 | continue; 86 | } 87 | $token = $tokens[$i]; 88 | $tokens[$i] = null; 89 | 90 | if ($blockstartRegexp->match($token)) { 91 | $name = $blockstartRegexp->matches[1]; 92 | $b[$name] = []; 93 | } elseif ($blockendRegexp->match($token)) { 94 | $name = null; 95 | } else { 96 | if ($name !== null) { 97 | array_push($b[$name], $token); 98 | } 99 | } 100 | } 101 | 102 | return $b; 103 | } 104 | 105 | /** 106 | * Parses the tokens 107 | * 108 | * @param array $tokens 109 | * 110 | * @throws \Liquid\Exception\MissingFilesystemException 111 | */ 112 | public function parse(array &$tokens) 113 | { 114 | if ($this->fileSystem === null) { 115 | throw new MissingFilesystemException("No file system"); 116 | } 117 | 118 | // read the source of the template and create a new sub document 119 | $source = $this->fileSystem->readTemplateFile($this->templateName); 120 | 121 | // tokens in this new document 122 | $maintokens = Template::tokenize($source); 123 | 124 | $eRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*extends (.*)?' . Liquid::get('TAG_END') . '$/'); 125 | foreach ($maintokens as $maintoken) { 126 | if ($eRegexp->match($maintoken)) { 127 | $m = $eRegexp->matches[1]; 128 | break; 129 | } 130 | } 131 | 132 | if (isset($m)) { 133 | $rest = array_merge($maintokens, $tokens); 134 | } else { 135 | $childtokens = $this->findBlocks($tokens); 136 | 137 | $blockstartRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*block (\w+)\s*(.*)?' . Liquid::get('TAG_END') . '$/'); 138 | $blockendRegexp = new Regexp('/^' . Liquid::get('TAG_START') . '\s*endblock\s*?' . Liquid::get('TAG_END') . '$/'); 139 | 140 | $name = null; 141 | 142 | $rest = []; 143 | $keep = false; 144 | 145 | for ($i = 0; $i < count($maintokens); $i++) { 146 | if ($blockstartRegexp->match($maintokens[$i])) { 147 | $name = $blockstartRegexp->matches[1]; 148 | 149 | if (isset($childtokens[$name])) { 150 | $keep = true; 151 | array_push($rest, $maintokens[$i]); 152 | foreach ($childtokens[$name] as $item) { 153 | array_push($rest, $item); 154 | } 155 | } 156 | } 157 | if (!$keep) { 158 | array_push($rest, $maintokens[$i]); 159 | } 160 | 161 | if ($blockendRegexp->match($maintokens[$i]) && $keep === true) { 162 | $keep = false; 163 | array_push($rest, $maintokens[$i]); 164 | } 165 | } 166 | } 167 | 168 | $cache = Template::getCache(); 169 | 170 | if (!$cache) { 171 | $this->document = new Document($rest, $this->fileSystem); 172 | return; 173 | } 174 | 175 | $this->hash = md5($source); 176 | 177 | $this->document = $cache->read($this->hash); 178 | 179 | if ($this->document == false || $this->document->hasIncludes() == true) { 180 | $this->document = new Document($rest, $this->fileSystem); 181 | $cache->write($this->hash, $this->document); 182 | } 183 | } 184 | 185 | /** 186 | * Check for cached includes; if there are - do not use cache 187 | * 188 | * @see Document::hasIncludes() 189 | * @return boolean 190 | */ 191 | public function hasIncludes() 192 | { 193 | if ($this->document->hasIncludes() == true) { 194 | return true; 195 | } 196 | 197 | $source = $this->fileSystem->readTemplateFile($this->templateName); 198 | 199 | if (Template::getCache()->exists(md5($source)) && $this->hash === md5($source)) { 200 | return false; 201 | } 202 | 203 | return true; 204 | } 205 | 206 | /** 207 | * Renders the node 208 | * 209 | * @param Context $context 210 | * 211 | * @return string 212 | */ 213 | public function render(Context $context) 214 | { 215 | $context->push(); 216 | $result = $this->document->render($context); 217 | $context->pop(); 218 | return $result; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagFor.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 79 | $this->variableName = $syntaxRegexp->matches[1]; 80 | $this->collectionName = $syntaxRegexp->matches[2]; 81 | $this->name = $syntaxRegexp->matches[1] . '-' . $syntaxRegexp->matches[2]; 82 | $this->extractAttributes($markup); 83 | } else { 84 | $syntaxRegexp = new Regexp('/(\w+)\s+in\s+\((\d+|' . Liquid::get('VARIABLE_NAME') . ')\s*\.\.\s*(\d+|' . Liquid::get('VARIABLE_NAME') . ')\)/'); 85 | if ($syntaxRegexp->match($markup)) { 86 | $this->type = 'digit'; 87 | $this->variableName = $syntaxRegexp->matches[1]; 88 | $this->start = $syntaxRegexp->matches[2]; 89 | $this->collectionName = $syntaxRegexp->matches[3]; 90 | $this->name = $syntaxRegexp->matches[1].'-digit'; 91 | $this->extractAttributes($markup); 92 | } else { 93 | throw new ParseException("Syntax Error in 'for loop' - Valid syntax: for [item] in [collection]"); 94 | } 95 | } 96 | } 97 | 98 | /** 99 | * Renders the tag 100 | * 101 | * @param Context $context 102 | * 103 | * @return null|string 104 | */ 105 | public function render(Context $context) 106 | { 107 | if (!isset($context->registers['for'])) { 108 | $context->registers['for'] = []; 109 | } 110 | 111 | if ($this->type == 'digit') { 112 | return $this->renderDigit($context); 113 | } 114 | 115 | // that's the default 116 | return $this->renderCollection($context); 117 | } 118 | 119 | private function renderCollection(Context $context) 120 | { 121 | $collection = $context->get($this->collectionName); 122 | 123 | if ($collection instanceof \Generator && !$collection->valid()) { 124 | return ''; 125 | } 126 | 127 | if ($collection instanceof \Traversable) { 128 | $collection = iterator_to_array($collection); 129 | } 130 | 131 | if (is_null($collection) || !is_array($collection) || count($collection) == 0) { 132 | return ''; 133 | } 134 | 135 | $range = [0, count($collection)]; 136 | 137 | if (isset($this->attributes['limit']) || isset($this->attributes['offset'])) { 138 | $offset = 0; 139 | 140 | if (isset($this->attributes['offset'])) { 141 | $offset = ($this->attributes['offset'] == 'continue') ? $context->registers['for'][$this->name] : $context->get($this->attributes['offset']); 142 | } 143 | 144 | $limit = (isset($this->attributes['limit'])) ? $context->get($this->attributes['limit']) : null; 145 | $rangeEnd = $limit ? $limit : count($collection) - $offset; 146 | $range = [$offset, $rangeEnd]; 147 | 148 | $context->registers['for'][$this->name] = $rangeEnd + $offset; 149 | } 150 | 151 | $result = ''; 152 | $segment = array_slice($collection, $range[0], $range[1]); 153 | if (!count($segment)) { 154 | return null; 155 | } 156 | 157 | $context->push(); 158 | $length = count($segment); 159 | 160 | $index = 0; 161 | foreach ($segment as $key => $item) { 162 | $value = is_numeric($key) ? $item : [$key, $item]; 163 | $context->set($this->variableName, $value); 164 | $context->set('forloop', [ 165 | 'name' => $this->name, 166 | 'length' => $length, 167 | 'index' => $index + 1, 168 | 'index0' => $index, 169 | 'rindex' => $length - $index, 170 | 'rindex0' => $length - $index - 1, 171 | 'first' => (int)($index == 0), 172 | 'last' => (int)($index == $length - 1), 173 | ]); 174 | 175 | $result .= $this->renderAll($this->nodelist, $context); 176 | 177 | $index++; 178 | 179 | if (isset($context->registers['break'])) { 180 | unset($context->registers['break']); 181 | break; 182 | } 183 | if (isset($context->registers['continue'])) { 184 | unset($context->registers['continue']); 185 | } 186 | } 187 | 188 | $context->pop(); 189 | 190 | return $result; 191 | } 192 | 193 | private function renderDigit(Context $context) 194 | { 195 | $start = $this->start; 196 | if (!is_integer($this->start)) { 197 | $start = $context->get($this->start); 198 | } 199 | 200 | $end = $this->collectionName; 201 | if (!is_integer($this->collectionName)) { 202 | $end = $context->get($this->collectionName); 203 | } 204 | 205 | $range = [$start, $end]; 206 | 207 | $context->push(); 208 | $result = ''; 209 | $index = 0; 210 | $length = $range[1] - $range[0]; 211 | for ($i = $range[0]; $i <= $range[1]; $i++) { 212 | $context->set($this->variableName, $i); 213 | $context->set('forloop', [ 214 | 'name' => $this->name, 215 | 'length' => $length, 216 | 'index' => $index + 1, 217 | 'index0' => $index, 218 | 'rindex' => $length - $index, 219 | 'rindex0' => $length - $index - 1, 220 | 'first' => (int)($index == 0), 221 | 'last' => (int)($index == $length - 1), 222 | ]); 223 | 224 | $result .= $this->renderAll($this->nodelist, $context); 225 | 226 | $index++; 227 | 228 | if (isset($context->registers['break'])) { 229 | unset($context->registers['break']); 230 | break; 231 | } 232 | if (isset($context->registers['continue'])) { 233 | unset($context->registers['continue']); 234 | } 235 | } 236 | 237 | $context->pop(); 238 | 239 | return $result; 240 | } 241 | } 242 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagIf.php: -------------------------------------------------------------------------------- 1 | nodelist = & $this->nodelistHolders[count($this->blocks)]; 57 | 58 | array_push($this->blocks, ['if', $markup, &$this->nodelist]); 59 | 60 | parent::__construct($markup, $tokens, $fileSystem); 61 | } 62 | 63 | /** 64 | * Handler for unknown tags, handle else tags 65 | * 66 | * @param string $tag 67 | * @param array $params 68 | * @param array $tokens 69 | */ 70 | public function unknownTag($tag, $params, array $tokens) 71 | { 72 | if ($tag == 'else' || $tag == 'elsif') { 73 | // Update reference to nodelistHolder for this block 74 | $this->nodelist = & $this->nodelistHolders[count($this->blocks) + 1]; 75 | $this->nodelistHolders[count($this->blocks) + 1] = []; 76 | 77 | array_push($this->blocks, [$tag, $params, &$this->nodelist]); 78 | } else { 79 | parent::unknownTag($tag, $params, $tokens); 80 | } 81 | } 82 | 83 | /** 84 | * Render the tag 85 | * 86 | * @param Context $context 87 | * 88 | * @throws \Liquid\Exception\ParseException 89 | * @return string 90 | */ 91 | public function render(Context $context) 92 | { 93 | $context->push(); 94 | 95 | $logicalRegex = new Regexp('/\s+(and|or)\s+/'); 96 | $conditionalRegex = new Regexp('/(' . Liquid::get('QUOTED_FRAGMENT') . ')\s*([=!<>a-z_]+)?\s*(' . Liquid::get('QUOTED_FRAGMENT') . ')?/'); 97 | 98 | $result = ''; 99 | foreach ($this->blocks as $block) { 100 | if ($block[0] == 'else') { 101 | $result = $this->renderAll($block[2], $context); 102 | 103 | break; 104 | } 105 | 106 | if ($block[0] == 'if' || $block[0] == 'elsif') { 107 | // Extract logical operators 108 | $logicalRegex->matchAll($block[1]); 109 | 110 | $logicalOperators = $logicalRegex->matches; 111 | $logicalOperators = $logicalOperators[1]; 112 | // Extract individual conditions 113 | $temp = $logicalRegex->split($block[1]); 114 | 115 | $conditions = []; 116 | 117 | foreach ($temp as $condition) { 118 | if ($conditionalRegex->match($condition)) { 119 | $left = (isset($conditionalRegex->matches[1])) ? $conditionalRegex->matches[1] : null; 120 | $operator = (isset($conditionalRegex->matches[2])) ? $conditionalRegex->matches[2] : null; 121 | $right = (isset($conditionalRegex->matches[3])) ? $conditionalRegex->matches[3] : null; 122 | 123 | array_push($conditions, [ 124 | 'left' => $left, 125 | 'operator' => $operator, 126 | 'right' => $right, 127 | ]); 128 | } else { 129 | throw new ParseException("Syntax Error in tag 'if' - Valid syntax: if [condition]"); 130 | } 131 | } 132 | if (count($logicalOperators)) { 133 | // If statement contains and/or 134 | $display = $this->interpretCondition($conditions[0]['left'], $conditions[0]['right'], $conditions[0]['operator'], $context); 135 | foreach ($logicalOperators as $k => $logicalOperator) { 136 | if ($logicalOperator == 'and') { 137 | $display = ($display && $this->interpretCondition($conditions[$k + 1]['left'], $conditions[$k + 1]['right'], $conditions[$k + 1]['operator'], $context)); 138 | } else { 139 | $display = ($display || $this->interpretCondition($conditions[$k + 1]['left'], $conditions[$k + 1]['right'], $conditions[$k + 1]['operator'], $context)); 140 | } 141 | } 142 | } else { 143 | // If statement is a single condition 144 | $display = $this->interpretCondition($conditions[0]['left'], $conditions[0]['right'], $conditions[0]['operator'], $context); 145 | } 146 | 147 | // hook for unless tag 148 | $display = $this->negateIfUnless($display); 149 | 150 | if ($display) { 151 | $result = $this->renderAll($block[2], $context); 152 | 153 | break; 154 | } 155 | } 156 | } 157 | 158 | $context->pop(); 159 | 160 | return $result; 161 | } 162 | 163 | protected function negateIfUnless($display) 164 | { 165 | // no need to negate a condition in a regular `if` tag (will do that in `unless` tag) 166 | return $display; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagIfchanged.php: -------------------------------------------------------------------------------- 1 | lastValue == $output) { 56 | return ''; 57 | } 58 | $this->lastValue = $output; 59 | return $this->lastValue; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagInclude.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 84 | throw new ParseException("Error in tag 'include' - Valid syntax: include '[template]' (with|for) [object|collection]"); 85 | } 86 | 87 | $unquoted = (strpos($regex->matches[1], '"') === false && strpos($regex->matches[1], "'") === false); 88 | 89 | $start = 1; 90 | $len = strlen($regex->matches[1]) - 2; 91 | 92 | if ($unquoted) { 93 | $start = 0; 94 | $len = strlen($regex->matches[1]); 95 | } 96 | 97 | $this->templateName = substr($regex->matches[1], $start, $len); 98 | 99 | if (isset($regex->matches[1])) { 100 | $this->collection = (isset($regex->matches[3])) ? ($regex->matches[3] == "for") : null; 101 | $this->variable = (isset($regex->matches[4])) ? $regex->matches[4] : null; 102 | } 103 | 104 | $this->extractAttributes($markup); 105 | 106 | parent::__construct($markup, $tokens, $fileSystem); 107 | } 108 | 109 | /** 110 | * Parses the tokens 111 | * 112 | * @param array $tokens 113 | * 114 | * @throws \Liquid\Exception\MissingFilesystemException 115 | */ 116 | public function parse(array &$tokens) 117 | { 118 | if ($this->fileSystem === null) { 119 | throw new MissingFilesystemException("No file system"); 120 | } 121 | 122 | // read the source of the template and create a new sub document 123 | $source = $this->fileSystem->readTemplateFile($this->templateName); 124 | 125 | $cache = Template::getCache(); 126 | 127 | if (!$cache) { 128 | // tokens in this new document 129 | $templateTokens = Template::tokenize($source); 130 | $this->document = new Document($templateTokens, $this->fileSystem); 131 | return; 132 | } 133 | 134 | $this->hash = md5($source); 135 | $this->document = $cache->read($this->hash); 136 | 137 | if ($this->document == false || $this->document->hasIncludes() == true) { 138 | $templateTokens = Template::tokenize($source); 139 | $this->document = new Document($templateTokens, $this->fileSystem); 140 | $cache->write($this->hash, $this->document); 141 | } 142 | } 143 | 144 | /** 145 | * Check for cached includes; if there are - do not use cache 146 | * 147 | * @see Document::hasIncludes() 148 | * @return boolean 149 | */ 150 | public function hasIncludes() 151 | { 152 | if ($this->document->hasIncludes() == true) { 153 | return true; 154 | } 155 | 156 | $source = $this->fileSystem->readTemplateFile($this->templateName); 157 | 158 | if (Template::getCache()->exists(md5($source)) && $this->hash === md5($source)) { 159 | return false; 160 | } 161 | 162 | return true; 163 | } 164 | 165 | /** 166 | * Renders the node 167 | * 168 | * @param Context $context 169 | * 170 | * @return string 171 | */ 172 | public function render(Context $context) 173 | { 174 | $result = ''; 175 | $variable = $context->get($this->variable); 176 | 177 | $context->push(); 178 | 179 | foreach ($this->attributes as $key => $value) { 180 | $context->set($key, $context->get($value)); 181 | } 182 | 183 | if ($this->collection) { 184 | foreach ($variable as $item) { 185 | $context->set($this->templateName, $item); 186 | $result .= $this->document->render($context); 187 | } 188 | } else { 189 | if (!is_null($this->variable)) { 190 | $context->set($this->templateName, $variable); 191 | } 192 | 193 | $result .= $this->document->render($context); 194 | } 195 | 196 | $context->pop(); 197 | 198 | return $result; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagIncrement.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 55 | $this->toIncrement = $syntax->matches[0]; 56 | } else { 57 | throw new ParseException("Syntax Error in 'increment' - Valid syntax: increment [var]"); 58 | } 59 | } 60 | 61 | /** 62 | * Renders the tag 63 | * 64 | * @param Context $context 65 | * 66 | * @return string|void 67 | */ 68 | public function render(Context $context) 69 | { 70 | // If the value is not set in the environment check to see if it 71 | // exists in the context, and if not set it to -1 72 | if (!isset($context->environments[0][$this->toIncrement])) { 73 | // check for a context value 74 | $from_context = $context->get($this->toIncrement); 75 | 76 | // we already have a value in the context 77 | $context->environments[0][$this->toIncrement] = (null !== $from_context) ? $from_context : -1; 78 | } 79 | 80 | // Increment the value 81 | $context->environments[0][$this->toIncrement]++; 82 | 83 | return ''; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagPaginate.php: -------------------------------------------------------------------------------- 1 | 30 | * {% endfor %} 31 | * {% endpaginate %} 32 | * 33 | */ 34 | 35 | class TagPaginate extends AbstractBlock 36 | { 37 | /** 38 | * @var array The collection to paginate 39 | */ 40 | private $collectionName; 41 | 42 | /** 43 | * @var array The collection object 44 | */ 45 | private $collection; 46 | 47 | /** 48 | * @var int The size of the collection 49 | */ 50 | private $collectionSize; 51 | 52 | /** 53 | * @var int The number of items to paginate by 54 | */ 55 | private $numberItems; 56 | 57 | /** 58 | * @var int The current page 59 | */ 60 | private $currentPage; 61 | 62 | /** 63 | * @var int The current offset (no of pages times no of items) 64 | */ 65 | private $currentOffset; 66 | 67 | /** 68 | * @var int Total pages 69 | */ 70 | private $totalPages; 71 | 72 | 73 | /** 74 | * Constructor 75 | * 76 | * @param string $markup 77 | * @param array $tokens 78 | * @param FileSystem|null $fileSystem 79 | * 80 | * @throws \Liquid\Exception\ParseException 81 | * 82 | */ 83 | public function __construct($markup, array &$tokens, ?FileSystem $fileSystem = null) 84 | { 85 | parent::__construct($markup, $tokens, $fileSystem); 86 | 87 | $syntax = new Regexp('/(' . Liquid::get('VARIABLE_NAME') . ')\s+by\s+(\w+)/'); 88 | 89 | if ($syntax->match($markup)) { 90 | $this->collectionName = $syntax->matches[1]; 91 | $this->numberItems = $syntax->matches[2]; 92 | $this->extractAttributes($markup); 93 | } else { 94 | throw new ParseException("Syntax Error - Valid syntax: paginate [collection] by [items]"); 95 | } 96 | } 97 | 98 | /** 99 | * Renders the tag 100 | * 101 | * @param Context $context 102 | * 103 | * @return string 104 | * 105 | */ 106 | public function render(Context $context) 107 | { 108 | $this->collection = $context->get($this->collectionName); 109 | 110 | if ($this->collection instanceof \Traversable) { 111 | $this->collection = iterator_to_array($this->collection); 112 | } 113 | 114 | if (!is_array($this->collection)) { 115 | // TODO do not throw up if error mode allows, see #83 116 | throw new RenderException("Missing collection with name '{$this->collectionName}'"); 117 | } 118 | 119 | // How many pages are there? 120 | $this->collectionSize = count($this->collection); 121 | $this->totalPages = ceil($this->collectionSize / $this->numberItems); 122 | 123 | // Whatever there is in the context, we need a number 124 | $this->currentPage = intval($context->get(Liquid::get('PAGINATION_CONTEXT_KEY'))); 125 | 126 | // Page number can only be between 1 and a number of pages 127 | $this->currentPage = max(1, min($this->currentPage, $this->totalPages)); 128 | 129 | // Find the offset and select that part 130 | $this->currentOffset = ($this->currentPage - 1) * $this->numberItems; 131 | $paginatedCollection = array_slice($this->collection, $this->currentOffset, $this->numberItems); 132 | 133 | // We must work in a new scope so we won't pollute a global scope 134 | $context->push(); 135 | 136 | // Sets the collection if it's a key of another collection (ie search.results, collection.products, blog.articles) 137 | $segments = explode('.', $this->collectionName); 138 | if (count($segments) == 2) { 139 | $context->set($segments[0], [$segments[1] => $paginatedCollection]); 140 | } else { 141 | $context->set($this->collectionName, $paginatedCollection); 142 | } 143 | 144 | $paginate = [ 145 | 'page_size' => $this->numberItems, 146 | 'current_page' => $this->currentPage, 147 | 'current_offset' => $this->currentOffset, 148 | 'pages' => $this->totalPages, 149 | 'items' => $this->collectionSize, 150 | ]; 151 | 152 | // Get the name of the request field to use in URLs 153 | $pageRequestKey = Liquid::get('PAGINATION_REQUEST_KEY'); 154 | 155 | if ($this->currentPage > 1) { 156 | $paginate['previous']['title'] = 'Previous'; 157 | $paginate['previous']['url'] = $this->currentUrl($context, [ 158 | $pageRequestKey => $this->currentPage - 1, 159 | ]); 160 | } 161 | 162 | if ($this->currentPage < $this->totalPages) { 163 | $paginate['next']['title'] = 'Next'; 164 | $paginate['next']['url'] = $this->currentUrl($context, [ 165 | $pageRequestKey => $this->currentPage + 1, 166 | ]); 167 | } 168 | 169 | $context->set('paginate', $paginate); 170 | 171 | $result = parent::render($context); 172 | 173 | $context->pop(); 174 | 175 | return $result; 176 | } 177 | 178 | /** 179 | * Returns the current page URL 180 | * 181 | * @param Context $context 182 | * @param array $queryPart 183 | * 184 | * @return string 185 | * 186 | */ 187 | public function currentUrl($context, $queryPart = []) 188 | { 189 | // From here we have $url->path and $url->query 190 | $url = (object) parse_url($context->get('REQUEST_URI') ?: ''); 191 | 192 | // Let's merge the query part 193 | if (isset($url->query)) { 194 | parse_str($url->query, $url->query); 195 | $url->query = array_merge($url->query, $queryPart); 196 | } else { 197 | $url->query = $queryPart; 198 | } 199 | 200 | $url->query = http_build_query($url->query); 201 | 202 | $scheme = $context->get('HTTPS') == 'on' ? 'https' : 'http'; 203 | 204 | return "$scheme://{$context->get('HTTP_HOST')}{$url->path}?{$url->query}"; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagRaw.php: -------------------------------------------------------------------------------- 1 | nodelist = []; 38 | 39 | for ($i = 0, $n = count($tokens); $i < $n; $i++) { 40 | if ($tokens[$i] === null) { 41 | continue; 42 | } 43 | $token = $tokens[$i]; 44 | $tokens[$i] = null; 45 | 46 | if ($tagRegexp->match($token)) { 47 | // If we found the proper block delimiter just end parsing here and let the outer block proceed 48 | if ($tagRegexp->matches[1] == $this->blockDelimiter()) { 49 | break; 50 | } 51 | } 52 | 53 | $this->nodelist[] = $token; 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagTablerow.php: -------------------------------------------------------------------------------- 1 | match($markup)) { 64 | $this->variableName = $syntax->matches[1]; 65 | $this->collectionName = $syntax->matches[2]; 66 | 67 | $this->extractAttributes($markup); 68 | } else { 69 | throw new ParseException("Syntax Error in 'table_row loop' - Valid syntax: table_row [item] in [collection] cols:3"); 70 | } 71 | } 72 | 73 | /** 74 | * Renders the current node 75 | * 76 | * @param Context $context 77 | * @throws \Liquid\Exception\RenderException 78 | * @return string 79 | */ 80 | public function render(Context $context) 81 | { 82 | $collection = $context->get($this->collectionName); 83 | 84 | if ($collection instanceof \Traversable) { 85 | $collection = iterator_to_array($collection); 86 | } 87 | 88 | if (!is_array($collection)) { 89 | throw new RenderException("Not an array"); 90 | } 91 | 92 | // discard keys 93 | $collection = array_values($collection); 94 | 95 | if (isset($this->attributes['limit']) || isset($this->attributes['offset'])) { 96 | $limit = $context->get($this->attributes['limit']); 97 | $offset = $context->get($this->attributes['offset']); 98 | $collection = array_slice($collection, $offset, $limit); 99 | } 100 | 101 | $length = count($collection); 102 | 103 | $cols = isset($this->attributes['cols']) ? $context->get($this->attributes['cols']) : PHP_INT_MAX; 104 | 105 | $row = 1; 106 | $col = 0; 107 | 108 | $result = "\n"; 109 | 110 | $context->push(); 111 | 112 | foreach ($collection as $index => $item) { 113 | $context->set($this->variableName, $item); 114 | $context->set('tablerowloop', [ 115 | 'length' => $length, 116 | 'index' => $index + 1, 117 | 'index0' => $index, 118 | 'rindex' => $length - $index, 119 | 'rindex0' => $length - $index - 1, 120 | 'first' => (int)($index == 0), 121 | 'last' => (int)($index == $length - 1), 122 | ]); 123 | 124 | $text = $this->renderAll($this->nodelist, $context); 125 | $break = isset($context->registers['break']); 126 | $continue = isset($context->registers['continue']); 127 | 128 | if ((!$break && !$continue) || strlen(trim($text)) > 0) { 129 | $result .= "$text"; 130 | } 131 | 132 | if ($col == $cols && !($index == $length - 1)) { 133 | $col = 0; 134 | $result .= "\n\n"; 135 | } 136 | 137 | if ($break) { 138 | unset($context->registers['break']); 139 | break; 140 | } 141 | if ($continue) { 142 | unset($context->registers['continue']); 143 | } 144 | } 145 | 146 | $context->pop(); 147 | 148 | $result .= "\n"; 149 | 150 | return $result; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Liquid/Tag/TagUnless.php: -------------------------------------------------------------------------------- 1 | parse(template_source); 24 | * $tpl->render(array('foo'=>1, 'bar'=>2); 25 | */ 26 | class Template 27 | { 28 | const CLASS_PREFIX = '\Liquid\Cache\\'; 29 | 30 | /** 31 | * @var Document The root of the node tree 32 | */ 33 | private $root; 34 | 35 | /** 36 | * @var FileSystem The file system to use for includes 37 | */ 38 | private $fileSystem; 39 | 40 | /** 41 | * @var array Globally included filters 42 | */ 43 | private $filters = []; 44 | 45 | /** 46 | * @var callable|null Called "sometimes" while rendering. For example to abort the execution of a rendering. 47 | */ 48 | private $tickFunction = null; 49 | 50 | /** 51 | * @var array Custom tags 52 | */ 53 | private static $tags = []; 54 | 55 | /** 56 | * @var Cache 57 | */ 58 | private static $cache; 59 | 60 | /** 61 | * Constructor. 62 | * 63 | * @param string $path 64 | * @param array|Cache $cache 65 | * 66 | * @return Template 67 | */ 68 | public function __construct($path = null, $cache = null) 69 | { 70 | $this->fileSystem = $path !== null 71 | ? new LocalFileSystem($path) 72 | : null; 73 | 74 | $this->setCache($cache); 75 | } 76 | 77 | /** 78 | * @param FileSystem $fileSystem 79 | */ 80 | public function setFileSystem(FileSystem $fileSystem) 81 | { 82 | $this->fileSystem = $fileSystem; 83 | } 84 | 85 | /** 86 | * @param array|Cache|null $cache 87 | * 88 | * @throws \Liquid\Exception\CacheException 89 | */ 90 | public static function setCache($cache) 91 | { 92 | if (is_array($cache)) { 93 | if (isset($cache['cache']) && class_exists($classname = self::CLASS_PREFIX . ucwords($cache['cache']))) { 94 | self::$cache = new $classname($cache); 95 | } else { 96 | throw new CacheException('Invalid cache options!'); 97 | } 98 | } 99 | 100 | if ($cache instanceof Cache) { 101 | self::$cache = $cache; 102 | } 103 | 104 | if (is_null($cache)) { 105 | self::$cache = null; 106 | } 107 | } 108 | 109 | /** 110 | * @return Cache 111 | */ 112 | public static function getCache() 113 | { 114 | return self::$cache; 115 | } 116 | 117 | /** 118 | * @return Document 119 | */ 120 | public function getRoot() 121 | { 122 | return $this->root; 123 | } 124 | 125 | /** 126 | * Register custom Tags 127 | * 128 | * @param string $name 129 | * @param string $class 130 | */ 131 | public static function registerTag($name, $class) 132 | { 133 | self::$tags[$name] = $class; 134 | } 135 | 136 | /** 137 | * @return array 138 | */ 139 | public static function getTags() 140 | { 141 | return self::$tags; 142 | } 143 | 144 | /** 145 | * Register the filter 146 | * 147 | * @param string $filter 148 | */ 149 | public function registerFilter($filter, ?callable $callback = null) 150 | { 151 | // Store callback for later use 152 | if ($callback) { 153 | $this->filters[] = [$filter, $callback]; 154 | } else { 155 | $this->filters[] = $filter; 156 | } 157 | } 158 | 159 | public function setTickFunction(callable $tickFunction) 160 | { 161 | $this->tickFunction = $tickFunction; 162 | } 163 | 164 | /** 165 | * Tokenizes the given source string 166 | * 167 | * @param string $source 168 | * 169 | * @return array 170 | */ 171 | public static function tokenize($source) 172 | { 173 | return empty($source) 174 | ? [] 175 | : preg_split(Liquid::get('TOKENIZATION_REGEXP'), $source, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE); 176 | } 177 | 178 | /** 179 | * Parses the given source string 180 | * 181 | * @param string $source 182 | * 183 | * @return Template 184 | */ 185 | public function parse($source) 186 | { 187 | if (!self::$cache) { 188 | return $this->parseAlways($source); 189 | } 190 | 191 | $hash = md5($source); 192 | $this->root = self::$cache->read($hash); 193 | 194 | // if no cached version exists, or if it checks for includes 195 | if ($this->root == false || $this->root->hasIncludes() == true) { 196 | $this->parseAlways($source); 197 | self::$cache->write($hash, $this->root); 198 | } 199 | 200 | return $this; 201 | } 202 | 203 | /** 204 | * Parses the given source string regardless of caching 205 | * 206 | * @param string $source 207 | * 208 | * @return Template 209 | */ 210 | private function parseAlways($source) 211 | { 212 | $tokens = Template::tokenize($source); 213 | $this->root = new Document($tokens, $this->fileSystem); 214 | 215 | return $this; 216 | } 217 | 218 | /** 219 | * Parses the given template file 220 | * 221 | * @param string $templatePath 222 | * @throws \Liquid\Exception\MissingFilesystemException 223 | * @return Template 224 | */ 225 | public function parseFile($templatePath) 226 | { 227 | if (!$this->fileSystem) { 228 | throw new MissingFilesystemException("Could not load a template without an initialized file system"); 229 | } 230 | 231 | return $this->parse($this->fileSystem->readTemplateFile($templatePath)); 232 | } 233 | 234 | /** 235 | * Renders the current template 236 | * 237 | * @param array $assigns an array of values for the template 238 | * @param array $filters additional filters for the template 239 | * @param array $registers additional registers for the template 240 | * 241 | * @return string 242 | */ 243 | public function render(array $assigns = [], $filters = null, array $registers = []) 244 | { 245 | $context = new Context($assigns, $registers); 246 | 247 | if ($this->tickFunction) { 248 | $context->setTickFunction($this->tickFunction); 249 | } 250 | 251 | if (!is_null($filters)) { 252 | if (is_array($filters)) { 253 | $this->filters = array_merge($this->filters, $filters); 254 | } else { 255 | $this->filters[] = $filters; 256 | } 257 | } 258 | 259 | foreach ($this->filters as $filter) { 260 | if (is_array($filter)) { 261 | // Unpack a callback saved as second argument 262 | $context->addFilters(...$filter); 263 | } else { 264 | $context->addFilters($filter); 265 | } 266 | } 267 | 268 | return $this->root->render($context); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /src/Liquid/Variable.php: -------------------------------------------------------------------------------- 1 | markup = $markup; 42 | 43 | $filterSep = new Regexp('/' . Liquid::get('FILTER_SEPARATOR') . '\s*(.*)/m'); 44 | $syntaxParser = new Regexp('/(' . Liquid::get('QUOTED_FRAGMENT') . ')(.*)/ms'); 45 | $filterParser = new Regexp('/(?:\s+|' . Liquid::get('QUOTED_FRAGMENT') . '|' . Liquid::get('ARGUMENT_SEPARATOR') . ')+/'); 46 | $filterArgsRegex = new Regexp('/(?:' . Liquid::get('FILTER_ARGUMENT_SEPARATOR') . '|' . Liquid::get('ARGUMENT_SEPARATOR') . ')\s*((?:\w+\s*\:\s*)?' . Liquid::get('QUOTED_FRAGMENT') . ')/'); 47 | 48 | $this->filters = []; 49 | if ($syntaxParser->match($markup)) { 50 | $nameMarkup = $syntaxParser->matches[1]; 51 | $this->name = $nameMarkup; 52 | $filterMarkup = $syntaxParser->matches[2]; 53 | 54 | if ($filterSep->match($filterMarkup)) { 55 | $filterParser->matchAll($filterSep->matches[1]); 56 | 57 | foreach ($filterParser->matches[0] as $filter) { 58 | $filter = trim($filter); 59 | if (preg_match('/\w+/', $filter, $matches)) { 60 | $filterName = $matches[0]; 61 | $filterArgsRegex->matchAll($filter); 62 | $matches = Liquid::arrayFlatten($filterArgsRegex->matches[1]); 63 | $this->filters[] = $this->parseFilterExpressions($filterName, $matches); 64 | } 65 | } 66 | } 67 | } 68 | 69 | if (Liquid::get('ESCAPE_BY_DEFAULT')) { 70 | // if auto_escape is enabled, and 71 | // - there's no raw filter, and 72 | // - no escape filter 73 | // - no other standard html-adding filter 74 | // then 75 | // - add a mandatory escape filter 76 | 77 | $addEscapeFilter = true; 78 | 79 | foreach ($this->filters as $filter) { 80 | // with empty filters set we would just move along 81 | if (in_array($filter[0], ['escape', 'escape_once', 'raw', 'newline_to_br'])) { 82 | // if we have any raw-like filter, stop 83 | $addEscapeFilter = false; 84 | break; 85 | } 86 | } 87 | 88 | if ($addEscapeFilter) { 89 | $this->filters[] = ['escape', []]; 90 | } 91 | } 92 | } 93 | 94 | /** 95 | * @param string $filterName 96 | * @param array $unparsedArgs 97 | * @return array 98 | */ 99 | private static function parseFilterExpressions($filterName, array $unparsedArgs) 100 | { 101 | $filterArgs = []; 102 | $keywordArgs = []; 103 | 104 | $justTagAttributes = new Regexp('/\A' . trim(Liquid::get('TAG_ATTRIBUTES'), '/') . '\z/'); 105 | 106 | foreach ($unparsedArgs as $a) { 107 | if ($justTagAttributes->match($a)) { 108 | $keywordArgs[$justTagAttributes->matches[1]] = $justTagAttributes->matches[2]; 109 | } else { 110 | $filterArgs[] = $a; 111 | } 112 | } 113 | 114 | if (count($keywordArgs)) { 115 | $filterArgs[] = $keywordArgs; 116 | } 117 | 118 | return [$filterName, $filterArgs]; 119 | } 120 | 121 | /** 122 | * Gets the variable name 123 | * 124 | * @return string The name of the variable 125 | */ 126 | public function getName() 127 | { 128 | return $this->name; 129 | } 130 | 131 | /** 132 | * Gets all Filters 133 | * 134 | * @return array 135 | */ 136 | public function getFilters() 137 | { 138 | return $this->filters; 139 | } 140 | 141 | /** 142 | * Renders the variable with the data in the context 143 | * 144 | * @param Context $context 145 | * 146 | * @return mixed|string 147 | */ 148 | public function render(Context $context) 149 | { 150 | $output = $context->get($this->name); 151 | foreach ($this->filters as $filter) { 152 | list($filtername, $filterArgKeys) = $filter; 153 | 154 | $filterArgValues = []; 155 | $keywordArgValues = []; 156 | 157 | foreach ($filterArgKeys as $arg_key) { 158 | if (is_array($arg_key)) { 159 | foreach ($arg_key as $keywordArgName => $keywordArgKey) { 160 | $keywordArgValues[$keywordArgName] = $context->get($keywordArgKey); 161 | } 162 | 163 | $filterArgValues[] = $keywordArgValues; 164 | } else { 165 | $filterArgValues[] = $context->get($arg_key); 166 | } 167 | } 168 | 169 | $output = $context->invoke($filtername, $output, $filterArgValues); 170 | } 171 | return $output; 172 | } 173 | } 174 | --------------------------------------------------------------------------------