File modules/util/Mustache.class.php
Last commit: Fri Feb 26 22:21:16 2021 +0100 Jan Dankert Fix: Supress warning in substr_count() for PHP 5 if length is 0.
1 <?php 2 3 4 namespace util; 5 6 7 /** 8 * Class Mustache. 9 * 10 * This is a simple Mustache template implementation, 11 * See https://mustache.github.io/ for the specification. 12 * 13 * This implementation has the following advantages: 14 * - no temporary files 15 * - no require calls or includes 16 * - no eval calls 17 * - no weird ob_start calls 18 * 19 * But this implementation is probably slower than "Bog the cow's" implementation: 20 * https://github.com/bobthecow/mustache.php 21 * 22 * But for some situations (i.e. for statifying CMS) this is not a problem ;) 23 * 24 * Features: 25 * - Simple values 26 * - Comments 27 * - Blocks (normal and negating blocks) 28 * - Lists with arrays and objects 29 * - Wrapper functions 30 * - Partials (you need to define a partial loader) 31 * - Delimiter change 32 * - no dot notation on property names 33 * 34 * Have a look for an example at the end of this file. 35 * 36 * Author: Jan Dankert 37 * License: GPL v3. 38 * 39 * @package cms\mustache 40 */ 41 class Mustache 42 { 43 /** 44 * Open Tag. 45 * @var string 46 */ 47 public $openTag = '{{'; 48 49 /** 50 * Closing tag. 51 * @var string 52 */ 53 public $closeTag = '}}'; 54 55 /** 56 * Escape function, feel free to set it to a function of your own. 57 * @var \Closure 58 */ 59 public $escape; 60 61 /** 62 * Partial loader. If you want to use partials, you have to define a partial loader in this member. 63 * @var \Closure 64 */ 65 public $partialLoader; 66 67 /** 68 * Root block. 69 * @var MustacheBlock 70 */ 71 private $root; 72 73 74 /** 75 * Creates a new Mustache parser with a given source. 76 * 77 * @param $source 78 */ 79 public function __construct( $source=null ) { 80 81 $escape = static function( $text) { 82 83 if ( is_scalar($text)) 84 return htmlentities( $text); 85 else 86 return false; 87 }; 88 $this->escape = $escape; 89 90 if ( $source ) 91 $this->parse($source); 92 } 93 94 95 /** 96 * Render the template. 97 * 98 * @param $data data 99 * @return string 100 */ 101 public function render( $data ) { 102 103 return $this->root->render( $data ); 104 } 105 106 /** 107 * Parsing the source. 108 * 109 * @param $source 110 */ 111 public function parse($source) 112 { 113 $tagList = array(); 114 $pos = 0; 115 116 $nextOpenTag = $this->openTag; 117 $nextClosTag = $this->closeTag; 118 119 while (true) { 120 121 $openTag = $nextOpenTag; 122 $closTag = $nextClosTag; 123 124 // searching for: {{#name}} 125 // ^ 126 $begin = strpos($source, $openTag, $pos); 127 if ( $begin === FALSE ) 128 break; 129 130 // searching for: {{#name}} 131 // ^ 132 $end = strpos($source, $closTag, $begin + strlen($openTag)); 133 if ( $end === FALSE ) 134 break; 135 136 // looking for: {{#name}} 137 // +---+ 138 $tagText = substr($source, $begin + strlen($openTag), $end - $begin - strlen($openTag)); 139 140 // Calculating line/column for error messages 141 $line = @substr_count($source, "\n", 0, $begin) + 1; 142 $column = $begin - strrpos(substr($source, 0, $begin), "\n") + ($line==1?1:0); 143 144 // Creating new Mustache tag. 145 $tag = new MustacheTag($tagText, $begin, $end+strlen($closTag) , $line, $column); 146 147 if ( $tag->type == MustacheTag::DELIM_CHANGE ) { 148 $nextDelimiters = explode(' ',$tag->propertyName); 149 if ( sizeof($nextDelimiters ) >= 2 ) { 150 $nextOpenTag = $nextDelimiters[0]; 151 $nextClosTag = substr($nextDelimiters[1],0,-1); 152 } 153 // Removing tag from source 154 $source = substr_replace($source,'',$begin,$end-$begin+strlen($closTag)); 155 // Delimiter-Tag is not added to the taglist, we don't need it. 156 $pos = $begin; 157 } 158 elseif ( $tag->type == MustacheTag::PARTIAL ) { 159 if ( !is_callable($this->partialLoader) ) 160 throw new \RuntimeException('No loader is defined, unable to inject a partial at '.$tag->__toString() ); 161 162 $loader = $this->partialLoader; 163 $partialSource = $loader( $tag->propertyName ); 164 // Removing tag from source 165 $source = substr_replace($source,$partialSource,$begin,$end-$begin+strlen($closTag)); 166 // Partial-Tag is not added to the taglist, we don't need it. 167 $pos = $begin; 168 } 169 else { 170 // All other tags are added to our list. 171 $tagList[] = $tag; 172 $pos = $end + strlen($closTag); 173 } 174 175 } 176 177 $this->parseStripTags( $source, $tagList ); 178 } 179 180 181 /** 182 * @param $source 183 * @param $tagList 184 */ 185 private function parseStripTags($source, $tagList) 186 { 187 $newTagList = array(); 188 $removedBytes = 0; 189 190 /** @var MustacheTag $tag */ 191 foreach($tagList as $tag ) { 192 193 $tagLength = $tag ->end - $tag->position; 194 195 $tag->position -= $removedBytes; 196 $tag->end = $tag->position; 197 198 $source = substr_replace($source,'',$tag->position,$tagLength); 199 $newTagList[] = $tag; 200 201 $removedBytes += $tagLength; 202 } 203 204 $this->root = $this->parseBlock($newTagList,$source); 205 } 206 207 /** 208 * @param $tagList 209 * @param $source 210 * @return MustacheBlock 211 */ 212 public function parseBlock($tagList, $source ) { 213 214 $block = new MustacheBlock(null); 215 $indent = 0; 216 $subtagList = array(); 217 $removedChars = 0; 218 $openTag = null; 219 220 /** @var MustacheTag $tag */ 221 foreach($tagList as $tag ) { 222 223 if ( $tag->kind == MustacheTag::OPEN ) { 224 if ( $indent == 0 ) 225 $openTag = $tag; 226 $indent++; 227 } 228 elseif ( $tag->kind == MustacheTag::CLOSE ) { 229 $indent--; 230 if ( $indent < 0) { 231 throw new \RuntimeException('Superfluous closing tag: '.$tag->__toString() ); 232 } 233 if ( $indent == 0 ) { 234 if ( !empty($tag->propertyName) && $tag->propertyName != $openTag->propertyName ) 235 throw new \RuntimeException('Start tag: '.$openTag->__toString().' does not match the closing tag: '.$tag->__toString() ); 236 237 $subSourceLength = $tag->position-$openTag->position; 238 $subsource = substr($source,$openTag->position-$removedChars,$subSourceLength); 239 240 $source = substr_replace($source,'',$openTag->position-$removedChars,$subSourceLength); 241 $openTag->position -= $removedChars; 242 $removedChars += $subSourceLength; 243 244 // Append new subblock. 245 $subBlock = $this->parseBlock($subtagList,$subsource); 246 $subtagList = array(); // resetting... 247 $subBlock->tag = $openTag; 248 249 $block->nodes[] = $subBlock; 250 } 251 } else { 252 if ( $indent == 0) { 253 // This tag belongs to this block. 254 $tag->position -= $removedChars; 255 256 switch( $tag->type ) { 257 case MustacheTag::COMMENT; 258 $node = new MustacheComment($tag); 259 break; 260 case MustacheTag::UNESCAPED; 261 $node = new MustacheValue($tag); 262 $node->escape = null; 263 break; 264 case MustacheTag::VARIABLE; 265 $node = new MustacheValue($tag); 266 $node->escape = $this->escape; 267 break; 268 default: 269 throw new \RuntimeException('Unsupported tag: '.$tag->__toString() ); 270 } 271 $block->nodes[] = $node; 272 273 } else { 274 // This is a tag of a subblock 275 $tag->position -= $openTag->position; 276 $subtagList[] = $tag; 277 } 278 } 279 } 280 281 if ( $indent > 0) { 282 throw new \RuntimeException('Missing closing tag for: '.$openTag->__toString() ); 283 } 284 285 $block->source = $source; 286 return $block; 287 } 288 289 } 290 291 class MustacheTag 292 { 293 /** 294 * The type of the tag. 295 * for example '#' (for sections) or '' (for variables). 296 * see the constants in this class. 297 * @var string 298 */ 299 public $type; 300 301 302 /** 303 * the kind of the tag. 304 * One of SIMPLE,OPEN,CLOSE 305 * @var int 306 */ 307 public $kind; 308 public $propertyName; 309 310 public $position; 311 public $end; 312 313 private $sourceLine; 314 private $sourceColumn; 315 316 const SIMPLE = 0; 317 const OPEN = 1; 318 const CLOSE = 2; 319 320 const CLOSING = '/'; 321 const NEGATION = '^'; 322 const SECTION = '#'; 323 const COMMENT = '!'; 324 const PARTIAL = '>'; 325 const PARENT = '<'; 326 const DELIM_CHANGE = '='; // Changing delimiter 327 const UNESCAPED_2 = '{'; 328 const UNESCAPED = '&'; 329 const PRAGMA = '%'; 330 const BLOCK_VAR = '$'; 331 332 const VARIABLE = ''; 333 334 private $VALID_TYPES = array( 335 self::CLOSING, self::NEGATION, self::SECTION, self::COMMENT, self::PARTIAL, self::PARENT, self::DELIM_CHANGE, self::UNESCAPED_2, self::UNESCAPED, self::PRAGMA, self::BLOCK_VAR 336 ); 337 338 339 /** 340 * MustacheTag constructor. 341 * @param $tagText string the tag, for example: "#name" 342 * @param $position 343 * @param $end 344 * @param $line 345 * @param $column 346 */ 347 public function __construct($tagText, $position, $end, $line, $column) 348 { 349 $this->sourceLine = $line; 350 $this->sourceColumn = $column; 351 $this->position = $position; 352 $this->end = $end; 353 354 $this->parseTag($tagText); 355 } 356 357 /** 358 * Textual representation of a Mustache tag, suitable for error reporting. 359 * @return string 360 */ 361 public function __toString() 362 { 363 return 'tag "'.$this->type . $this->propertyName.'" (@ pos ' . $this->sourceLine . ':' . $this->sourceColumn . ') '; 364 } 365 366 private function parseTag($tagText) 367 { 368 $t = substr($tagText, 0, 1); 369 if (in_array($t, $this->VALID_TYPES)) { 370 $this->type = $t; 371 $property = substr($tagText, 1); 372 $this->propertyName = trim($property); 373 if ( $t == self::SECTION || $t == self::NEGATION ) 374 $this->kind = self::OPEN; 375 elseif ( $t == self::CLOSING ) 376 $this->kind = self::CLOSE; 377 else 378 $this->kind = self::SIMPLE; 379 } else { 380 $this->type = self::VARIABLE; 381 $this->propertyName = trim($tagText); 382 $this->kind = self::SIMPLE; 383 } 384 385 } 386 } 387 388 class MustacheNode { 389 390 public $type; 391 392 /** 393 * @var MustacheTag 394 */ 395 public $tag; 396 397 public function __construct( $tag ) 398 { 399 $this->tag = $tag; 400 } 401 402 public function render( $data ) { 403 return ''; 404 } 405 406 public function getValue( $data ) { 407 408 if ( !is_object($this->tag)) 409 return false; // on root-block, there is no tag. 410 411 $value = $data; 412 413 // Evaluate "dot notation" 414 foreach( explode('.',$this->tag->propertyName ) as $key ) 415 { 416 if ( is_array($value) && isset($value[$key]) ) { 417 $value = $value[$key]; 418 continue; 419 } 420 $value = false; // Key does not exist, so there is no value. 421 } 422 423 424 if ( is_object($value)) 425 { 426 if ($value instanceof \Closure) 427 ; // anonymous functions 428 else 429 $value = get_object_vars($value); 430 } 431 432 return $value; 433 } 434 } 435 436 437 class MustacheBlock extends MustacheNode { 438 439 /** 440 * @var String 441 */ 442 public $source; 443 444 /** 445 * @var MustacheNode 446 */ 447 public $nodes = array(); 448 449 /** 450 * Should this block be rendered? 451 * 452 * @param $data data 453 * @return bool 454 */ 455 public function isRendered( $data ) { 456 457 if ( !is_object($this->tag)) 458 return true; // on root-block, there is no tag. 459 460 $propIsTrue = (boolean) $this->getValue( $data ); 461 462 if ( $this->tag->type == MustacheTag::NEGATION ) 463 $propIsTrue = ! $propIsTrue; 464 465 return $propIsTrue; 466 } 467 468 public function render($data) 469 { 470 if ( $this->isRendered($data ) ) { 471 472 $values = $this->getValue($data); 473 if ( !is_array($values) || !isset($values[0]) ) 474 $values = array( $values); 475 476 $sumOutput = ''; 477 foreach( $values as $value) { 478 479 $data = array_merge($data,(array) $value ); 480 $output = $this->source; 481 $insertedBytes = 0; 482 483 484 /** @var MustacheNode $node */ 485 foreach($this->nodes as $node) { 486 487 if ( $node instanceof MustacheBlock ) 488 $o = $node->render( $data); 489 else 490 { 491 $o = $node->render($data); 492 if ( is_callable($value) ) 493 $o = $value( $o ); 494 } 495 496 $value = $this->getValue($data); 497 if ( is_callable($value) ) 498 $o = $value($o); 499 500 $output = substr_replace($output, $o, $node->tag->position+$insertedBytes, 0); 501 $insertedBytes += strlen($o); 502 } 503 $sumOutput .= $output; 504 } 505 506 return $sumOutput; 507 } 508 else { 509 return ''; 510 } 511 512 } 513 } 514 515 class MustacheValue extends MustacheNode { 516 517 /** 518 * escaping function. 519 */ 520 public $escape; 521 522 public function render($data) 523 { 524 $value = $this->getValue($data); 525 526 if ( is_callable( $this->escape)) { 527 528 $escape = $this->escape; 529 $value = $escape($value); 530 } 531 532 return $value; 533 } 534 } 535 536 class MustacheComment extends MustacheNode { 537 538 public function render($data) 539 { 540 return ''; 541 } 542 } 543 544 545 /* 546 * Example. 547 * 548 * Uncomment the following for a working example. 549 */ 550 551 /* 552 error_reporting(E_ALL); 553 ini_set('display_errors', 1); 554 555 $source = <<<SRC 556 Hello {{planet}}, {{& planet }}.{{! Simple example with a simple property }} 557 558 {{#test}} 559 Yes, this is a {{test}}. {{! yes, it is}} 560 {{/test}} 561 {{^test}} 562 No, this is not a {{test}}. {{ ! will not be displayed, because test is not false }} 563 {{/test}} 564 565 {{#car}} 566 My Car is {{color}}. {{! this is a property of the array car }} 567 It drives on {{& planet }}.{{! this property is inherited from the upper context }} 568 {{/}} 569 570 {{#house}} 571 My House is {{size}}. {{! this property is read from an object }} 572 {{/}} {{! short closing tags are allowed }} 573 574 Some names: 575 {{#names}} 576 my name is {{ name }}.{{! yes, spaces are allowed}} 577 {{/names}} 578 579 {{#empty}} 580 this is not displayed {{! because the list is empty }} 581 {{/empty}} 582 583 {{#upper}} 584 Hello again, {{planet}}. {{!displayed in uppercase}} 585 {{/}} 586 587 <h1>Partials</h1> 588 {{> mycoolpartial}} 589 590 <h1>Changing Delimiters</h1> 591 Default: {{name}} 592 {{=$( )=}} 593 Bash-Style: $(name) 594 Default should not work here: {{name}} 595 596 $(={{ }}=) 597 Default again: {{name}} 598 599 <h1>Dot notation</h1> 600 this will not work: {{building}} 601 but this is the color of the roof: {{building.roof.color}} 602 603 604 SRC; 605 606 $m = new Mustache(); 607 $m->partialLoader = function($name) { 608 return "\nThis is a partial named ".$name.". It may include variables, like the name '{{name}}'.\n\n"; 609 }; 610 $m->parse( $source ); 611 612 echo 'Object: <pre><code>'; print_r($m); echo '</code></pre>'; 613 614 $data = array( 615 'planet' => '<b>world</b>', 616 'test' => 'Test', 617 'name' => 'Mallory', 618 'car' => array('color'=>'red'), 619 'house' => (object) array('size'=>'big' ), 620 'names' => array( 621 array('name'=>'Alice'), 622 array('name'=>'Bob') 623 ), 624 'empty' => array(), 625 'upper' => static function($text) { return strtoupper($text); }, 626 'building' => array('roof'=>array('color'=>'gray')) 627 628 ); 629 630 echo '<pre>'.$m->render( $data ).'</pre>'; 631 */
Downloadmodules/util/Mustache.class.php
History Fri, 26 Feb 2021 22:21:16 +0100 Jan Dankert Fix: Supress warning in substr_count() for PHP 5 if length is 0. Sat, 22 Feb 2020 23:58:02 +0100 Jan Dankert Refactoring: Namespacing for module 'util'. Fri, 15 Nov 2019 23:56:23 +0100 Jan Dankert Fix: #boolval() is not available in PHP 5.4 Tue, 12 Nov 2019 00:17:33 +0100 Jan Dankert Fix: Parse partials from the beginning. Mon, 11 Nov 2019 22:46:30 +0100 Jan Dankert New enhancement for Mustache-Templates: - Partials (you need to define a partial loader) - Delimiter change - dot notation on property names Thu, 16 May 2019 23:37:52 +0200 Jan Dankert Fix: Comments must be closed before the file ending. Mon, 6 May 2019 20:42:59 +0200 Jan Dankert Kompatibilität zu PHP 5.5: Klassenkonstanten können keine Arrays enthalten. Sat, 4 May 2019 22:48:47 +0200 Jan Dankert Fix: Initialisieren der Node-Liste (falls das Template keine Tags enthält). Sat, 4 May 2019 03:01:17 +0200 Jan Dankert Neuer Parser für Mustache-Templates. Benötigt keine temporären Dateien und kein require(). Dieser soll den bisherigen einfachen Template-Mechanismus ablösen.