├── .github └── workflows │ └── php.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── phpcs.xml.dist ├── phpstan.neon ├── phpstan.neon.dist ├── phpunit.xml.dist ├── src ├── ArrayExtension.php ├── DateExtension.php ├── PcreExtension.php └── TextExtension.php └── tests ├── ArrayExtensionTest.php ├── DateExtensionTest.php ├── PcreExtensionTest.php ├── Support └── TestHelper.php └── TextExtensionTest.php /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHP 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | run: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | include: 16 | - php: 7.4 17 | composer: '--prefer-lowest' 18 | desc: "Lowest versions" 19 | - php: 7.4 20 | - php: 8.0 21 | - php: 8.1 22 | - php: 8.2 23 | coverage: '--coverage-clover /tmp/clover.xml' 24 | - php: 8.3 25 | name: PHP ${{ matrix.php }} ${{ matrix.desc }} 26 | 27 | steps: 28 | - uses: actions/checkout@v4 29 | with: 30 | fetch-depth: 2 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php }} 36 | coverage: xdebug 37 | 38 | - name: Validate composer.json and composer.lock 39 | run: composer validate 40 | 41 | - name: Install dependencies 42 | run: composer update --prefer-dist --no-progress ${{ matrix.composer }} 43 | 44 | - name: Run PHPUnit 45 | run: vendor/bin/phpunit ${{ matrix.coverage }} 46 | 47 | - name: Upload Scrutinizer coverage 48 | uses: sudo-bot/action-scrutinizer@latest 49 | if: ${{ matrix.coverage }} 50 | with: 51 | cli-args: "--format=php-clover build/logs/clover.xml --revision=${{ github.event.pull_request.head.sha || github.sha }}" 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /composer.lock 3 | .phpunit.result.cache 4 | .idea 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) Arnold Daniels 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Jasny Twig Extensions 2 | ======================= 3 | 4 | [![PHP](https://github.com/jasny/twig-extensions/actions/workflows/php.yml/badge.svg)](https://github.com/jasny/twig-extensions/actions/workflows/php.yml) 5 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/jasny/twig-extensions/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/jasny/twig-extensions/?branch=master) 6 | [![Code Coverage](https://scrutinizer-ci.com/g/jasny/twig-extensions/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/jasny/twig-extensions/?branch=master) 7 | [![Packagist Stable Version](https://img.shields.io/packagist/v/jasny/twig-extensions.svg)](https://packagist.org/packages/jasny/twig-extensions) 8 | [![Packagist License](https://img.shields.io/packagist/l/jasny/twig-extensions.svg)](https://packagist.org/packages/jasny/twig-extensions) 9 | 10 | A number of useful filters for Twig. 11 | 12 | ## Installation 13 | 14 | Jasny's Twig Extensions can be easily installed using [composer](http://getcomposer.org/) 15 | 16 | composer require jasny/twig-extensions 17 | 18 | ## Usage 19 | 20 | ```php 21 | $twig = new Twig_Environment($loader, $options); 22 | $twig->addExtension(new Jasny\Twig\DateExtension()); 23 | $twig->addExtension(new Jasny\Twig\PcreExtension()); 24 | $twig->addExtension(new Jasny\Twig\TextExtension()); 25 | $twig->addExtension(new Jasny\Twig\ArrayExtension()); 26 | ``` 27 | 28 | To use in a Symfony project [register the extensions as a service](http://symfony.com/doc/current/cookbook/templating/twig_extension.html#register-an-extension-as-a-service). 29 | 30 | ```yaml 31 | services: 32 | twig.extension.date: 33 | class: Jasny\Twig\DateExtension 34 | tags: 35 | - { name: twig.extension } 36 | 37 | twig.extension.pcre: 38 | class: Jasny\Twig\PcreExtension 39 | tags: 40 | - { name: twig.extension } 41 | 42 | twig.extension.text: 43 | class: Jasny\Twig\TextExtension 44 | tags: 45 | - { name: twig.extension } 46 | 47 | twig.extension.array: 48 | class: Jasny\Twig\ArrayExtension 49 | tags: 50 | - { name: twig.extension } 51 | ``` 52 | 53 | 54 | ## Date extension 55 | 56 | Format a date based on the current locale. Requires the [intl extension](http://www.php.net/intl). 57 | 58 | * localdate - Format the date value as a string based on the current locale 59 | * localtime - Format the time value as a string based on the current locale 60 | * localdatetime - Format the date/time value as a string based on the current locale 61 | * age - Get the age (in years) based on a date 62 | * duration - Get the duration string from seconds 63 | 64 | ```php 65 | Locale::setDefault(LC_ALL, "en_US"); // vs "nl_NL" 66 | ``` 67 | 68 | ``` 69 | {{"now"|localdate('long')}} 70 | {{"now"|localtime('short')}} 71 | {{"2013-10-01 23:15:00"|localdatetime}} 72 | {{"22-08-1981"|age}} 73 | {{ 3600|duration }} 74 | ``` 75 | 76 | 77 | ## PCRE 78 | 79 | Exposes [PCRE](http://www.php.net/pcre) to Twig. 80 | 81 | * preg_quote - Quote regular expression characters 82 | * preg_match - Perform a regular expression match 83 | * preg_get - Perform a regular expression match and return the matched group 84 | * preg_get_all - Perform a regular expression match and return the group for all matches 85 | * preg_grep - Perform a regular expression match and return an array of entries that match the pattern 86 | * preg_replace - Perform a regular expression search and replace 87 | * preg_filter - Perform a regular expression search and replace, returning only matched subjects. 88 | * preg_split - Split text into an array using a regular expression 89 | 90 | ``` 91 | {% if client.email|preg_match('/^.+@.+\.\w+$/') %}Email: {{ client.email }}{% endif %} 92 | Website: {{ client.website|preg_replace('~^https?://~') 93 | First name: {{ client.fullname|preg_get('/^\S+/') }} 94 | 99 | ``` 100 | 101 | 102 | ## Text ## 103 | 104 | Convert text to HTML + string functions 105 | 106 | * paragraph - Add HTML paragraph and line breaks to text 107 | * line - Get a single line of text 108 | * less - Cut of text on a page break 109 | * truncate - Cut off text if it's too long 110 | * linkify - Turn all URLs into clickable links (also supports Twitter @user and #subject) 111 | 112 | 113 | ## Array ## 114 | 115 | Brings PHP's array functions to Twig 116 | 117 | * sum - Calculate the sum of values in an array 118 | * product - Calculate the product of values in an array 119 | * values - Return all the values of an array 120 | * as_array - Cast an object to an associated array 121 | * html_attr - Turn an array into an HTML attribute string 122 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jasny/twig-extensions", 3 | "description": "A set of useful Twig filters", 4 | "keywords": ["templating", "PCRE", "preg", "regex", "date", "time", "datetime", "text", "array"], 5 | "license": "MIT", 6 | "homepage": "http://github.com/jasny/twig-extensions#README", 7 | "authors": [ 8 | { 9 | "name": "Arnold Daniels", 10 | "email": "arnold@jasny.net", 11 | "homepage": "http://www.jasny.net" 12 | } 13 | ], 14 | "support": { 15 | "issues": "https://github.com/jasny/twig-extensions/issues", 16 | "source": "https://github.com/jasny/twig-extensions" 17 | }, 18 | "require": { 19 | "php": ">=7.4.0", 20 | "twig/twig": "^2.7 | ^3.0" 21 | }, 22 | "suggest": { 23 | "ext-intl": "Required for the use of the LocalDate Twig extension", 24 | "ext-pcre": "Required for the use of the PCRE Twig extension" 25 | }, 26 | "autoload": { 27 | "psr-4": { 28 | "Jasny\\Twig\\": "src/" 29 | } 30 | }, 31 | "require-dev": { 32 | "ext-intl": "*", 33 | "ext-pcre": "*", 34 | "ext-json": "*", 35 | "phpstan/phpstan": "^1.12.0", 36 | "phpunit/phpunit": "^9.6", 37 | "squizlabs/php_codesniffer": "^3.10" 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "Jasny\\Twig\\Tests\\": "tests/" 42 | } 43 | }, 44 | "minimum-stability": "dev", 45 | "prefer-stable": true, 46 | "scripts": { 47 | "test": [ 48 | "phpstan analyse", 49 | "XDEBUG_MODE=coverage phpunit --testdox --colors=always --coverage-text", 50 | "phpcs -p src" 51 | ] 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /phpcs.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | The Jasny coding standard. 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 5 3 | paths: 4 | - src 5 | 6 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 0 3 | paths: 4 | - src 5 | 6 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | src 6 | 7 | 8 | 9 | 10 | tests/ 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ArrayExtension.php: -------------------------------------------------------------------------------- 1 | $value) { 89 | if (!isset($value) || $value === false) { 90 | continue; 91 | } 92 | 93 | if ($value === true) { 94 | $value = $key; 95 | } 96 | 97 | $str .= ' ' . $key . '="' . addcslashes($value, '"') . '"'; 98 | } 99 | 100 | return trim($str); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/DateExtension.php: -------------------------------------------------------------------------------- 1 | getFormat($dateFormat) : null; 80 | $timeType = isset($timeFormat) ? $this->getFormat($timeFormat) : null; 81 | 82 | $calendarConst = $calendar === 'traditional' ? \IntlDateFormatter::TRADITIONAL : \IntlDateFormatter::GREGORIAN; 83 | 84 | $pattern = $this->getDateTimePattern( 85 | $dateType ?? $dateFormat, 86 | $timeType ?? $timeFormat, 87 | $calendarConst 88 | ); 89 | 90 | return new \IntlDateFormatter(\Locale::getDefault(), $dateType, $timeType, null, $calendarConst, $pattern); 91 | } 92 | 93 | /** 94 | * Format the date/time value as a string based on the current locale 95 | * 96 | * @param string $format 'short', 'medium', 'long', 'full', 'none' or false 97 | * @return int|null 98 | */ 99 | protected function getFormat(string $format): ?int 100 | { 101 | $types = [ 102 | 'none' => \IntlDateFormatter::NONE, 103 | 'short' => \IntlDateFormatter::SHORT, 104 | 'medium' => \IntlDateFormatter::MEDIUM, 105 | 'long' => \IntlDateFormatter::LONG, 106 | 'full' => \IntlDateFormatter::FULL 107 | ]; 108 | 109 | return $types[$format] ?? null; 110 | } 111 | 112 | /** 113 | * Get the date/time pattern. 114 | * 115 | * @param int|string|null $dateType 116 | * @param int|string|null $timeType 117 | * @param int $calendar 118 | * @return string 119 | */ 120 | protected function getDateTimePattern($dateType, $timeType, int $calendar = \IntlDateFormatter::GREGORIAN): ?string 121 | { 122 | if (is_int($dateType) && is_int($timeType)) { 123 | return null; 124 | } 125 | 126 | return $this->getDatePattern( 127 | $dateType ?? \IntlDateFormatter::SHORT, 128 | $timeType ?? \IntlDateFormatter::SHORT, 129 | $calendar 130 | ); 131 | } 132 | 133 | /** 134 | * Get the formatter to create a date and/or time pattern 135 | * 136 | * @param int|string $dateType 137 | * @param int|string $timeType 138 | * @param int $calendar 139 | * @return \IntlDateFormatter 140 | */ 141 | protected function getDatePatternFormatter( 142 | $dateType, 143 | $timeType, 144 | int $calendar = \IntlDateFormatter::GREGORIAN 145 | ): \IntlDateFormatter { 146 | return \IntlDateFormatter::create( 147 | \Locale::getDefault(), 148 | is_int($dateType) ? $dateType : \IntlDateFormatter::NONE, 149 | is_int($timeType) ? $timeType : \IntlDateFormatter::NONE, 150 | \IntlTimeZone::getGMT(), 151 | $calendar 152 | ); 153 | } 154 | 155 | /** 156 | * Get the date and/or time pattern 157 | * Default date pattern is short date pattern with 4 digit year. 158 | * 159 | * @param int|string $dateType 160 | * @param int|string $timeType 161 | * @param int $calendar 162 | * @return string 163 | */ 164 | protected function getDatePattern($dateType, $timeType, int $calendar = \IntlDateFormatter::GREGORIAN): string 165 | { 166 | $createPattern = 167 | (is_int($dateType) && $dateType !== \IntlDateFormatter::NONE) || 168 | (is_int($timeType) && $timeType !== \IntlDateFormatter::NONE); 169 | 170 | $pattern = $createPattern ? $this->getDatePatternFormatter($dateType, $timeType, $calendar)->getPattern() : ''; 171 | 172 | return trim( 173 | (is_string($dateType) ? $dateType . ' ' : '') . 174 | preg_replace('/\byy?\b/', 'yyyy', $pattern) . 175 | (is_string($timeType) ? ' ' . $timeType : '') 176 | ); 177 | } 178 | 179 | /** 180 | * Format the date and/or time value as a string based on the current locale 181 | * 182 | * @param DateTime|int|string|null $value 183 | * @param string|null $dateFormat null, 'none', 'short', 'medium', 'long', 'full' or pattern 184 | * @param string|null $timeFormat null, 'none', 'short', 'medium', 'long', 'full' or pattern 185 | * @param string $calendar 'gregorian' or 'traditional' 186 | * @return string|null 187 | */ 188 | protected function formatLocal( 189 | $value, 190 | ?string $dateFormat, 191 | ?string $timeFormat, 192 | string $calendar = 'gregorian' 193 | ): ?string { 194 | if (!isset($value)) { 195 | return null; 196 | } 197 | 198 | $date = $this->valueToDateTime($value); 199 | $formatter = $this->getDateFormatter($dateFormat, $timeFormat, $calendar); 200 | 201 | return $formatter->format($date->getTimestamp()); 202 | } 203 | 204 | /** 205 | * Format the date value as a string based on the current locale 206 | * 207 | * @param DateTime|int|string|null $date 208 | * @param string|null $format null, 'short', 'medium', 'long', 'full' or pattern 209 | * @param string $calendar 'gregorian' or 'traditional' 210 | * @return string|null 211 | */ 212 | public function localDate($date, ?string $format = null, string $calendar = 'gregorian'): ?string 213 | { 214 | return $this->formatLocal($date, $format, 'none', $calendar); 215 | } 216 | 217 | /** 218 | * Format the time value as a string based on the current locale 219 | * 220 | * @param DateTime|int|string|null $date 221 | * @param string $format 'short', 'medium', 'long', 'full' or pattern 222 | * @param string $calendar 'gregorian' or 'traditional' 223 | * @return string|null 224 | */ 225 | public function localTime($date, string $format = 'short', string $calendar = 'gregorian'): ?string 226 | { 227 | return $this->formatLocal($date, 'none', $format, $calendar); 228 | } 229 | 230 | /** 231 | * Format the date/time value as a string based on the current locale 232 | * 233 | * @param DateTime|int|string|null $date 234 | * @param string|array|\stdClass|null $format date format, pattern or ['date'=>format, 'time'=>format) 235 | * @param string $calendar 'gregorian' or 'traditional' 236 | * @return string 237 | */ 238 | public function localDateTime($date, $format = null, string $calendar = 'gregorian'): ?string 239 | { 240 | if (is_array($format) || $format instanceof \stdClass || !isset($format)) { 241 | $formatDate = $format['date'] ?? null; 242 | $formatTime = $format['time'] ?? 'short'; 243 | } else { 244 | $formatDate = $format; 245 | $formatTime = false; 246 | } 247 | 248 | return $this->formatLocal($date, $formatDate, $formatTime, $calendar); 249 | } 250 | 251 | 252 | /** 253 | * Split duration into seconds, minutes, hours, days, weeks and years. 254 | * 255 | * @param int $seconds 256 | * @param int $max 257 | * @return array 258 | */ 259 | protected function splitDuration(int $seconds, int $max): array 260 | { 261 | if ($max < 1 || $seconds < 60) { 262 | return [$seconds]; 263 | } 264 | 265 | $minutes = floor($seconds / 60); 266 | $seconds = $seconds % 60; 267 | if ($max < 2 || $minutes < 60) { 268 | return [$seconds, $minutes]; 269 | } 270 | 271 | $hours = floor($minutes / 60); 272 | $minutes = $minutes % 60; 273 | if ($max < 3 || $hours < 24) { 274 | return [$seconds, $minutes, $hours]; 275 | } 276 | 277 | $days = floor($hours / 24); 278 | $hours = $hours % 24; 279 | if ($max < 4 || $days < 7) { 280 | return [$seconds, $minutes, $hours, $days]; 281 | } 282 | 283 | $weeks = floor($days / 7); 284 | $days = $days % 7; 285 | if ($max < 5 || $weeks < 52) { 286 | return [$seconds, $minutes, $hours, $days, $weeks]; 287 | } 288 | 289 | $years = floor($weeks / 52); 290 | $weeks = $weeks % 52; 291 | return [$seconds, $minutes, $hours, $days, $weeks, $years]; 292 | } 293 | 294 | /** 295 | * Calculate duration from seconds. 296 | * One year is seen as exactly 52 weeks. 297 | * 298 | * Use null to skip a unit. 299 | * 300 | * @param int|null $value Time in seconds 301 | * @param array $units Time units (seconds, minutes, hours, days, weeks, years) 302 | * @param string $separator 303 | * @return string 304 | */ 305 | public function duration( 306 | ?int $value, 307 | array $units = ['s', 'm', 'h', 'd', 'w', 'y'], 308 | string $separator = ' ' 309 | ): ?string { 310 | if (!isset($value)) { 311 | return null; 312 | } 313 | 314 | $parts = $this->splitDuration($value, count($units) - 1) + array_fill(0, 6, null); 315 | 316 | $duration = ''; 317 | 318 | for ($i = 5; $i >= 0; $i--) { 319 | if (isset($parts[$i]) && isset($units[$i])) { 320 | $duration .= $separator . $parts[$i] . $units[$i]; 321 | } 322 | } 323 | 324 | return trim($duration, $separator); 325 | } 326 | 327 | /** 328 | * Get the age (in years) based on a date. 329 | * 330 | * @param DateTime|string|null $value 331 | * @return int|null 332 | */ 333 | public function age($value): ?int 334 | { 335 | if (!isset($value)) { 336 | return null; 337 | } 338 | 339 | $date = $this->valueToDateTime($value); 340 | 341 | return (int)$date->diff(new DateTime())->format('%y'); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/PcreExtension.php: -------------------------------------------------------------------------------- 1 | pregMatch($pattern, $value) > 0; 109 | } 110 | 111 | /** 112 | * Perform a regular expression match and return a matched group. 113 | */ 114 | public function get(?string $value, string $pattern, int $group = 0): ?string 115 | { 116 | if (!isset($value)) { 117 | return null; 118 | } 119 | 120 | return $this->pregMatch($pattern, $value, $matches) > 0 && isset($matches[$group]) ? $matches[$group] : null; 121 | } 122 | 123 | /** 124 | * Perform a regular expression match and return the group for all matches. 125 | */ 126 | public function getAll(?string $value, string $pattern, int $group = 0): ?array 127 | { 128 | if (!isset($value)) { 129 | return null; 130 | } 131 | 132 | $ret = preg_match_all($pattern, $value, $matches, PREG_PATTERN_ORDER); 133 | 134 | if ($ret === false) { 135 | throw new RuntimeError("Error in regular expression: $pattern"); 136 | } 137 | 138 | return $ret > 0 && isset($matches[$group]) ? $matches[$group] : []; 139 | } 140 | 141 | /** 142 | * Perform a regular expression match and return an array of entries that match the pattern 143 | * 144 | * @param array|null $values 145 | * @param string $pattern 146 | * @param string $flags Optional 'invert' to return entries that do not match the given pattern. 147 | * @return array 148 | */ 149 | public function grep(?array $values, string $pattern, string $flags = ''): ?array 150 | { 151 | if (!isset($values)) { 152 | return null; 153 | } 154 | 155 | if (is_string($flags)) { 156 | $flags = $flags === 'invert' ? PREG_GREP_INVERT : 0; 157 | } 158 | 159 | $ret = preg_grep($pattern, $values, $flags); 160 | 161 | if ($ret === false) { 162 | throw new RuntimeError("Error in regular expression: $pattern"); 163 | } 164 | 165 | return $ret; 166 | } 167 | 168 | /** 169 | * Perform a regular expression search and replace. 170 | * 171 | * @param string|array|null $value 172 | * @param string|array $pattern 173 | * @param string|array $replacement 174 | * @param int $limit 175 | * @return string|array|null 176 | * @throws RuntimeError 177 | */ 178 | public function replace($value, $pattern, $replacement = '', int $limit = -1) 179 | { 180 | $this->assertNoEval(...(array)$pattern); 181 | 182 | if (!isset($value)) { 183 | return null; 184 | } 185 | 186 | $ret = preg_replace($pattern, $replacement, $value, $limit); 187 | 188 | if ($ret === null) { 189 | throw new RuntimeError("Error in regular expression: $pattern"); 190 | } 191 | 192 | return $ret; 193 | } 194 | 195 | /** 196 | * Perform a regular expression search and replace, returning only matched subjects. 197 | * 198 | * @param string|array|null $value 199 | * @param string|array $pattern 200 | * @param string|array $replacement 201 | * @param int $limit 202 | * @return string|array|null 203 | * @throws RuntimeError 204 | */ 205 | public function filter($value, $pattern, $replacement = '', int $limit = -1) 206 | { 207 | $this->assertNoEval(...(array)$pattern); 208 | 209 | if (!isset($value)) { 210 | return null; 211 | } 212 | 213 | $ret = preg_filter($pattern, $replacement, $value, $limit); 214 | 215 | if ($ret === null) { 216 | throw new RuntimeError("Error in regular expression: $pattern"); 217 | } 218 | 219 | return $ret; 220 | } 221 | 222 | /** 223 | * Split text into an array using a regular expression. 224 | */ 225 | public function split(?string $value, string $pattern): array 226 | { 227 | if (!isset($value)) { 228 | return []; 229 | } 230 | 231 | $ret = preg_split($pattern, $value); 232 | 233 | if ($ret === false) { 234 | throw new RuntimeError("Error in regular expression: $pattern"); 235 | } 236 | 237 | return $ret; 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/TextExtension.php: -------------------------------------------------------------------------------- 1 | 'html', 'is_safe' => ['html']]), 30 | new TwigFilter('line', [$this, 'line']), 31 | new TwigFilter('less', [$this, 'less'], ['pre_escape' => 'html', 'is_safe' => ['html']]), 32 | new TwigFilter('truncate', [$this, 'truncate'], ['pre_escape' => 'html', 'is_safe' => ['html']]), 33 | new TwigFilter('linkify', [$this, 'linkify'], ['pre_escape' => 'html', 'is_safe' => ['html']]) 34 | ]; 35 | } 36 | 37 | /** 38 | * Add paragraph and line breaks to text. 39 | */ 40 | public function paragraph(?string $value): ?string 41 | { 42 | if (!isset($value)) { 43 | return null; 44 | } 45 | 46 | return '

' . preg_replace(['~\n(\s*)\n\s*~', '~(?)\n\s*~'], ["

\n\$1

", "
\n"], trim($value)) . 47 | '

'; 48 | } 49 | 50 | /** 51 | * Get a single line 52 | * 53 | * @param string|null $value 54 | * @param int $line Line number (starts at 1) 55 | * @return string|null 56 | */ 57 | public function line(?string $value, int $line = 1): ?string 58 | { 59 | if (!isset($value)) { 60 | return null; 61 | } 62 | 63 | $lines = explode("\n", $value); 64 | 65 | return $lines[$line - 1] ?? null; 66 | } 67 | 68 | /** 69 | * Cut of text on a page break. 70 | */ 71 | public function less(?string $value, string $replace = '...', string $break = ''): ?string 72 | { 73 | if (!isset($value)) { 74 | return null; 75 | } 76 | 77 | $pos = stripos($value, $break); 78 | return $pos === false ? $value : substr($value, 0, $pos) . $replace; 79 | } 80 | 81 | /** 82 | * Cut of text if it's too long. 83 | */ 84 | public function truncate(?string $value, int $length, string $replace = '...'): ?string 85 | { 86 | if (!isset($value)) { 87 | return null; 88 | } 89 | 90 | return strlen($value) <= $length ? $value : substr($value, 0, $length - strlen($replace)) . $replace; 91 | } 92 | 93 | /** 94 | * Linkify a HTTP(S) link. 95 | * 96 | * @param string $protocol 'http' or 'https' 97 | * @param string $text 98 | * @param array $links OUTPUT 99 | * @param string $attr 100 | * @param string $mode 101 | * @return string 102 | */ 103 | protected function linkifyHttp(string $protocol, string $text, array &$links, string $attr, string $mode): string 104 | { 105 | $regexp = $mode != 'all' 106 | ? '~(?:(https?)://([^\s<>]+)|(?]+?\.[^\s<>]+))(?]+)|(?@]+?\.[^\s<>]+)(?' 116 | . rtrim($link, '/') . '') . '>'; 117 | }, $text); 118 | } 119 | 120 | /** 121 | * Linkify a mail link. 122 | * 123 | * @param string $text 124 | * @param array $links OUTPUT 125 | * @param string $attr 126 | * @return string 127 | */ 128 | protected function linkifyMail(string $text, array &$links, string $attr): string 129 | { 130 | $regexp = '~([^\s<>]+?@[^\s<>]+?\.[^\s<>]+)(?' . $match[1] . '') 134 | . '>'; 135 | }, $text); 136 | } 137 | 138 | 139 | /** 140 | * Linkify a link. 141 | * 142 | * @param string $protocol 143 | * @param string $text 144 | * @param array $links OUTPUT 145 | * @param string $attr 146 | * @param string $mode 147 | * @return string 148 | */ 149 | protected function linkifyOther(string $protocol, string $text, array &$links, string $attr, string $mode): string 150 | { 151 | if (strpos($protocol, ':') === false) { 152 | $protocol .= in_array($protocol, ['ftp', 'tftp', 'ssh', 'scp']) ? '://' : ':'; 153 | } 154 | 155 | $regexp = $mode != 'all' 156 | ? '~' . preg_quote($protocol, '~') . '([^\s<>]+)(?]+)(?' . $match[1] 161 | . '') . '>'; 162 | }, $text); 163 | } 164 | 165 | /** 166 | * Turn all URLs in clickable links. 167 | * 168 | * @param string|null $value 169 | * @param array|string $protocols 'http'/'https', 'mail' and also 'ftp', 'scp', 'tel', etc 170 | * @param array $attributes HTML attributes for the link 171 | * @param string $mode normal or all 172 | * @return string 173 | */ 174 | public function linkify( 175 | ?string $value, 176 | $protocols = ['http', 'mail'], 177 | array $attributes = [], 178 | string $mode = 'normal' 179 | ): ?string { 180 | if (!isset($value)) { 181 | return null; 182 | } 183 | 184 | // Link attributes 185 | $attr = ''; 186 | foreach ($attributes as $key => $val) { 187 | $attr .= ' ' . $key . '="' . htmlentities($val) . '"'; 188 | } 189 | 190 | $links = []; 191 | 192 | // Extract existing links and tags 193 | $text = preg_replace_callback('~(.*?|<.*?>)~i', function ($match) use (&$links) { 194 | return '<' . array_push($links, $match[1]) . '>'; 195 | }, $value); 196 | 197 | // Extract text links for each protocol 198 | foreach ((array)$protocols as $protocol) { 199 | switch ($protocol) { 200 | case 'http': 201 | case 'https': 202 | $text = $this->linkifyHttp($protocol, $text, $links, $attr, $mode); 203 | break; 204 | case 'mail': 205 | $text = $this->linkifyMail($text, $links, $attr); 206 | break; 207 | default: 208 | $text = $this->linkifyOther($protocol, $text, $links, $attr, $mode); 209 | break; 210 | } 211 | } 212 | 213 | // Insert all link 214 | return preg_replace_callback('/<(\d+)>/', function ($match) use (&$links) { 215 | return $links[$match[1] - 1]; 216 | }, $text); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /tests/ArrayExtensionTest.php: -------------------------------------------------------------------------------- 1 | assertRender('10', '{{ data|sum }}', compact('data')); 27 | } 28 | 29 | public function testProduct() 30 | { 31 | $data = [1, 2, 3, 4]; 32 | 33 | $this->assertRender('24', '{{ data|product }}', compact('data')); 34 | } 35 | 36 | public function testValues() 37 | { 38 | $data = (object)['foo' => 1, 'bar' => 2, 'zoo' => 3]; 39 | 40 | $this->assertRender('1-2-3', '{{ data|values|join("-") }}', compact('data')); 41 | } 42 | 43 | 44 | public function testAsArrayWithObject() 45 | { 46 | $data = (object)['foo' => 1, 'bar' => 2, 'zoo' => 3]; 47 | 48 | $this->assertRender('foo-bar-zoo', '{{ data|as_array|keys|join("-") }}', compact('data')); 49 | } 50 | 51 | public function testAsArrayWithString() 52 | { 53 | $data = 'foo'; 54 | 55 | $this->assertRender('foo', '{{ data|as_array|join("-") }}', compact('data')); 56 | } 57 | 58 | public function testHtmlAttr() 59 | { 60 | $data = ['href' => 'foo.html', 'class' => 'big small', 'checked' => true, 'disabled' => false]; 61 | 62 | $this->assertRender( 63 | 'href="foo.html" class="big small" checked="checked"', 64 | '{{ data|html_attr|raw }}', 65 | compact('data') 66 | ); 67 | } 68 | 69 | 70 | public function filterProvider() 71 | { 72 | return [ 73 | ['sum'], 74 | ['product'], 75 | ['values'], 76 | ['as_array'], 77 | ['html_attr'] 78 | ]; 79 | } 80 | 81 | /** 82 | * @dataProvider filterProvider 83 | * 84 | * @param string $filter 85 | */ 86 | public function testWithNull($filter) 87 | { 88 | $this->assertRender('-', '{{ null|' . $filter . '("//")|default("-") }}'); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /tests/DateExtensionTest.php: -------------------------------------------------------------------------------- 1 | assertRender($en, $template); 58 | } 59 | 60 | /** 61 | * @dataProvider localDateTimeProvider 62 | * 63 | * @param string $en 64 | * @param string $nl 65 | * @param string $template 66 | */ 67 | public function testLocalDateTimeNL($en, $nl, $template): void 68 | { 69 | \Locale::setDefault("nl_NL"); 70 | $this->assertRender($nl, $template); 71 | } 72 | 73 | 74 | public function durationProvider(): array 75 | { 76 | return [ 77 | ['31s', "{{ 31|duration }}"], 78 | ['17m 31s', "{{ 1051|duration }}"], 79 | ['3h 17m 31s', "{{ 11851|duration }}"], 80 | ['2d 3h 17m 31s', "{{ 184651|duration }}"], 81 | ['3w 2d 3h 17m 31s', "{{ 1999051|duration }}"], 82 | ['1y 3w 2d 3h 17m 31s', "{{ 33448651|duration }}"], 83 | 84 | ['17 minute(s)', "{{ 1051|duration([null, ' minute(s)', ' hour(s)', ' day(s)']) }}"], 85 | ['3 hour(s)', "{{ 11851|duration([null, null, ' hour(s)']) }}"], 86 | ['2 day(s)', "{{ 184651|duration([null, null, null, ' day(s)']) }}"], 87 | ['3 week(s)', "{{ 1999051|duration([null, null, null, null, ' week(s)']) }}"], 88 | ['1 year(s)', "{{ 33448651|duration([null, null, null, null, null, ' year(s)']) }}"], 89 | 90 | ['3u:17m', "{{ 11851|duration([null, 'm', 'u'], ':') }}"], 91 | ['3:17h', "{{ 11851|duration([null, '', ''], ':') }}h"], 92 | ]; 93 | } 94 | 95 | /** 96 | * @dataProvider durationProvider 97 | * 98 | * @param string $expect 99 | * @param string $template 100 | */ 101 | public function testDuration($expect, $template) 102 | { 103 | $this->assertRender($expect, $template); 104 | } 105 | 106 | 107 | public function ageProvider(): array 108 | { 109 | $time = time() - (((32 * 365) + 100) * 24 * 3600); 110 | $date = date('Y-m-d', $time); 111 | 112 | return [ 113 | ['32', "{{ $time|age }}"], 114 | ['32', "{{ '$date'|age }}"] 115 | ]; 116 | } 117 | 118 | /** 119 | * @dataProvider ageProvider 120 | * 121 | * @param string $expect 122 | * @param string $template 123 | */ 124 | public function testAge($expect, $template) 125 | { 126 | $this->assertRender($expect, $template); 127 | } 128 | 129 | 130 | public static function filterProvider(): array 131 | { 132 | return [ 133 | ['localdate'], 134 | ['localtime'], 135 | ['localdatetime'], 136 | ['duration'], 137 | ['age'] 138 | ]; 139 | } 140 | 141 | /** 142 | * @dataProvider filterProvider 143 | */ 144 | public function testWithNull(string $filter, $arg = null) 145 | { 146 | $call = $filter . ($arg ? '(' . json_encode($arg) . ')' : ''); 147 | $this->assertRender('-', '{{ null|' . $call . '|default("-") }}'); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /tests/PcreExtensionTest.php: -------------------------------------------------------------------------------- 1 | assertEquals('jasny/pcre', $this->getExtension()->getName()); 26 | } 27 | 28 | 29 | public function testQuote() 30 | { 31 | $this->assertRender('foo\(\)', '{{ "foo()"|preg_quote }}'); 32 | } 33 | 34 | public function testQuoteDelimiter() 35 | { 36 | $this->assertRender('foo\@bar', '{{ "foo@bar"|preg_quote("@") }}'); 37 | } 38 | 39 | public function testPregMatch() 40 | { 41 | $this->assertRender('YES', '{% if "foo"|preg_match("/oo/") %}YES{% else %}NO{% endif %}'); 42 | } 43 | 44 | public function testPregMatchNo() 45 | { 46 | $this->assertRender('NO', '{% if "fod"|preg_match("/oo/") %}YES{% else %}NO{% endif %}'); 47 | } 48 | 49 | public function testPregMatchError() 50 | { 51 | $this->expectException(TwigRuntimeError::class); 52 | $this->render('{% if "fod"|preg_match("/o//o/") %}YES{% else %}NO{% endif %}'); 53 | } 54 | 55 | 56 | public function testPregGet() 57 | { 58 | $this->assertRender('d', '{{ "food"|preg_get("/oo(.)/", 1) }}'); 59 | } 60 | 61 | public function testPregGetDefault() 62 | { 63 | $this->assertRender('ood', '{{ "food"|preg_get("/oo(.)/") }}'); 64 | } 65 | 66 | 67 | public function testPregGetAll() 68 | { 69 | $this->assertRender('d|t|m', '{{ "food woot should doom"|preg_get_all("/oo(.)/", 1)|join("|") }}'); 70 | } 71 | 72 | public function testPregGetAllDefault() 73 | { 74 | $this->assertRender('ood|oot|oom', '{{ "food woot doom"|preg_get_all("/oo(.)/")|join("|") }}'); 75 | } 76 | 77 | 78 | public function testPregGrep() 79 | { 80 | $this->assertRender( 81 | 'world|how|you', 82 | '{{ ["hello", "sweet", "world", "how", "are", "you"]|preg_grep("/o./")|join("|") }}' 83 | ); 84 | } 85 | 86 | public function testPregGrepInvert() 87 | { 88 | $this->assertRender( 89 | 'hello|sweet|are', 90 | '{{ ["hello", "sweet", "world", "how", "are", "you"]|preg_grep("/o./", "invert")|join("|") }}' 91 | ); 92 | } 93 | 94 | 95 | public function testReplace() 96 | { 97 | $this->assertRender( 98 | 'the quick brawen faxe jumped aveer the lazy dage', 99 | '{{ "the quick brown fox jumped over the lazy dog"|preg_replace("/o(\\\\w)/", "a$1e") }}' 100 | ); 101 | } 102 | 103 | public function testReplaceLimit() 104 | { 105 | $this->assertRender( 106 | 'the quick brawen faxe jumped over the lazy dog', 107 | '{{ "the quick brown fox jumped over the lazy dog"|preg_replace("/o(\\\\w)/", "a$1e", 2) }}' 108 | ); 109 | } 110 | 111 | public function testReplaceWithArray() 112 | { 113 | $this->assertRender( 114 | 'hello|sweet|wareld|hawe|are|yaue', 115 | '{{ ["hello", "sweet", "world", "how", "are", "you"]|preg_replace("/o(.)/", "a$1e")|join("|") }}' 116 | ); 117 | } 118 | 119 | public function testReplaceWithArrayOfPatterns() 120 | { 121 | $this->assertRender( 122 | '0000AAAA', 123 | "{{ '1234ABCD'|preg_replace(['/\\\\d/','/[A-Z]/'],['0', 'A']) }}" 124 | ); 125 | } 126 | 127 | public function testReplaceAssertNoEval() 128 | { 129 | $this->expectException(TwigRuntimeError::class); 130 | $this->render('{{ "foo"|preg_replace("/o/ei", "strtoupper") }}'); 131 | } 132 | 133 | 134 | public function testFilter() 135 | { 136 | $this->assertRender( 137 | 'wareld|hawe|yaue', 138 | '{{ ["hello", "sweet", "world", "how", "are", "you"]|preg_filter("/o(.)/", "a$1e")|join("|") }}' 139 | ); 140 | } 141 | 142 | public function testFilterAssertNoEval() 143 | { 144 | $this->expectException(TwigRuntimeError::class); 145 | $this->render('{{ "foo"|preg_filter("/o/ei", "strtoupper") }}'); 146 | } 147 | 148 | 149 | public function testSplit() 150 | { 151 | $this->assertRender( 152 | 'the quick br|n f| jumped |er the lazy d|', 153 | '{{ "the quick brown fox jumped over the lazy dog"|preg_split("/o(\\\\w)/", "a$1e")|join("|") }}' 154 | ); 155 | } 156 | 157 | 158 | public function filterProvider() 159 | { 160 | return [ 161 | ['preg_quote'], 162 | ['preg_match'], 163 | ['preg_get'], 164 | ['preg_get_all'], 165 | ['preg_grep'], 166 | ['preg_replace'], 167 | ['preg_filter'], 168 | ['preg_split'] 169 | ]; 170 | } 171 | 172 | /** 173 | * @dataProvider filterProvider 174 | * 175 | * @param string $filter 176 | */ 177 | public function testWithNull($filter) 178 | { 179 | $this->assertRender('-', '{{ null|' . $filter . '("//")|default("-") }}'); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /tests/Support/TestHelper.php: -------------------------------------------------------------------------------- 1 | $template, 31 | ]); 32 | $twig = new Environment($loader); 33 | 34 | $twig->addExtension($this->getExtension()); 35 | 36 | return $twig; 37 | } 38 | 39 | /** 40 | * Render template 41 | * 42 | * @param string $template 43 | * @param array $data 44 | * @return string 45 | */ 46 | protected function render($template, array $data = []) 47 | { 48 | $twig = $this->buildEnv($template); 49 | $result = $twig->render('template', $data); 50 | 51 | return $result; 52 | } 53 | 54 | /** 55 | * Render template and assert equals 56 | * 57 | * @param string $expected 58 | * @param string $template 59 | * @param array $data 60 | */ 61 | protected function assertRender($expected, $template, array $data = []) 62 | { 63 | $result = $this->render($template, $data); 64 | 65 | $this->assertEquals($expected, (string)$result); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/TextExtensionTest.php: -------------------------------------------------------------------------------- 1 | assertRender("

foo
\nbar

\n

monkey

", "{{ 'foo\nbar\n\nmonkey'|paragraph() }}"); 25 | } 26 | 27 | 28 | public function testLine() 29 | { 30 | $this->assertRender("foo", "{{ 'foo\nbar\nbaz'|line() }}"); 31 | } 32 | 33 | public function testLineTwo() 34 | { 35 | $this->assertRender("bar", "{{ 'foo\nbar\nbaz'|line(2) }}"); 36 | } 37 | 38 | public function testLineToHigh() 39 | { 40 | $this->assertRender("", "{{ 'foo\nbar\nbaz'|line(100) }}"); 41 | } 42 | 43 | 44 | public function testLess() 45 | { 46 | $this->assertRender("foo...", "{{ 'foobaz'|less() }}"); 47 | } 48 | 49 | public function testLessCustom() 50 | { 51 | $this->assertRender("foo..", "{{ 'fooXbarXbaz'|less('..', 'X') }}"); 52 | } 53 | 54 | public function testLessNoPageBreak() 55 | { 56 | $this->assertRender("foo bar", "{{ 'foo bar'|less }}"); 57 | } 58 | 59 | 60 | public function testTruncate() 61 | { 62 | $this->assertRender("foo...", "{{ 'foo bar baz'|truncate(6) }}"); 63 | } 64 | 65 | public function testTruncateCustom() 66 | { 67 | $this->assertRender("foo ..", "{{ 'foo bar baz'|truncate(6, '..') }}"); 68 | } 69 | 70 | public function testTruncateToHigh() 71 | { 72 | $this->assertRender("foo bar baz", "{{ 'foo bar baz'|truncate(100) }}"); 73 | } 74 | 75 | 76 | public function testLinkify() 77 | { 78 | $this->assertRender( 79 | 'www.example.com, color.bar and ' 80 | . 'john@example.com', 81 | '{{ "www.example.com, color.bar and john@example.com"|linkify }}' 82 | ); 83 | } 84 | 85 | public function testLinkifyAll() 86 | { 87 | $this->assertRender( 88 | 'www.example.com, color.bar and ' 89 | . 'john@example.com', 90 | '{{ "www.example.com, color.bar and john@example.com"|linkify(["http", "mail"], [], "all") }}' 91 | ); 92 | } 93 | 94 | public function testLinkifyHttps() 95 | { 96 | $this->assertRender( 97 | 'www.example.com', 98 | '{{ "www.example.com"|linkify("https") }}' 99 | ); 100 | } 101 | 102 | public function testLinkifyMail() 103 | { 104 | $this->assertRender( 105 | 'john@example.com and ' 106 | . 'jeff@example.com', 107 | '{{ "john@example.com and jeff@example.com"|linkify }}' 108 | ); 109 | } 110 | 111 | public function testLinkifyFtp() 112 | { 113 | $this->assertRender( 114 | 'www.example.com', 115 | '{{ "ftp://www.example.com"|linkify("ftp") }}' 116 | ); 117 | } 118 | 119 | public function testLinkifyFtpAll() 120 | { 121 | $this->assertRender( 122 | 'www.example.com', 123 | '{{ "www.example.com"|linkify("ftp", [], "all") }}' 124 | ); 125 | } 126 | 127 | public function testLinkifyOther() 128 | { 129 | $this->assertRender( 130 | 'abc.def.hif', 131 | '{{ "foo:abc.def.hif"|linkify("foo") }}' 132 | ); 133 | } 134 | 135 | public function testLinkifyOtherAll() 136 | { 137 | $this->assertRender( 138 | 'abc.def.hif', 139 | '{{ "abc.def.hif"|linkify("foo", [], "all") }}' 140 | ); 141 | } 142 | 143 | public function testLinkifyWithAttributes() 144 | { 145 | $this->assertRender( 146 | 'www.example.com and ' 147 | . 'john@example.com', 148 | '{{ "www.example.com and john@example.com"|linkify(["http", "mail"], {foo: "bar", color: "blue"}) }}' 149 | ); 150 | } 151 | 152 | public function testLinkifyWithExistingLink() 153 | { 154 | $this->assertRender( 155 | 'www.example.com and ' 156 | . 'www.example.net', 157 | '{{ "www.example.com and www.example.net"|linkify }}' 158 | ); 159 | } 160 | 161 | 162 | public static function filterProvider() 163 | { 164 | return [ 165 | 'paragraph' => ['paragraph', '/'], 166 | 'line' => ['line', 1], 167 | 'less' => ['less'], 168 | 'truncate' => ['truncate', 10], 169 | 'linkify' => ['linkify'] 170 | ]; 171 | } 172 | 173 | /** 174 | * @dataProvider filterProvider 175 | */ 176 | public function testWithNull(string $filter, $arg = null) 177 | { 178 | $call = $filter . ($arg ? '(' . json_encode($arg) . ')' : ''); 179 | $this->assertRender('-', '{{ null|' . $call . '|default("-") }}'); 180 | } 181 | } 182 | --------------------------------------------------------------------------------