From 23a4bb04f28778174ba4102b59f7b59caf86ebc5 Mon Sep 17 00:00:00 2001 From: Justin Hileman Date: Wed, 14 Mar 2012 19:48:19 -0700 Subject: [PATCH] Add custom variable escapers. cf. #86 * Add `escape` Mustache constructor option. Expects something callable. * Update Compiler to either inline `htmlspecialchars` (old way) or a call to the custom escaper callback. * Update Compiler to statically compile charset. * Update template classname to account for charset and custom compilers. --- src/Mustache/Compiler.php | 33 ++++++++++++++++++++------ src/Mustache/Mustache.php | 36 +++++++++++++++++++++++++++-- test/Mustache/Test/CompilerTest.php | 22 ++++++++++++++---- test/Mustache/Test/MustacheTest.php | 17 ++++++++++++++ 4 files changed, 94 insertions(+), 14 deletions(-) diff --git a/src/Mustache/Compiler.php b/src/Mustache/Compiler.php index daebe2e..f8c8494 100644 --- a/src/Mustache/Compiler.php +++ b/src/Mustache/Compiler.php @@ -21,6 +21,8 @@ class Compiler { private $sections; private $source; private $indentNextLine; + private $customEscape; + private $charset; /** * Compile a Mustache token parse tree into PHP source code. @@ -31,10 +33,12 @@ class Compiler { * * @return string Generated PHP source code */ - public function compile($source, array $tree, $name) { + public function compile($source, array $tree, $name, $customEscape = false, $charset = 'UTF-8') { $this->sections = array(); $this->source = $source; $this->indentNextLine = true; + $this->customEscape = $customEscape; + $this->charset = $charset; return $this->writeCode($tree, $name); } @@ -116,10 +120,10 @@ class Compiler { %s if ($escape) { - return htmlspecialchars($buffer, ENT_COMPAT, $this->mustache->getCharset()); + return %s; + } else { + return $buffer; } - - return $buffer; } %s }'; @@ -136,7 +140,7 @@ class Compiler { $code = $this->walk($tree); $sections = implode("\n", $this->sections); - return sprintf($this->prepare(self::KLASS, 0, false), $name, $code, $sections); + return sprintf($this->prepare(self::KLASS, 0, false), $name, $code, $this->getEscape('$buffer'), $sections); } const SECTION_CALL = ' @@ -247,7 +251,6 @@ class Compiler { } $buffer .= %s%s; '; - const VARIABLE_ESCAPED = 'htmlspecialchars($value, ENT_COMPAT, $this->mustache->getCharset())'; /** * Generate Mustache Template variable interpolation PHP source. @@ -261,7 +264,7 @@ class Compiler { private function variable($id, $escape, $level) { $method = $this->getFindMethod($id); $id = ($method !== 'last') ? var_export($id, true) : ''; - $value = $escape ? self::VARIABLE_ESCAPED : '$value'; + $value = $escape ? $this->getEscape() : '$value'; return sprintf($this->prepare(self::VARIABLE, $level), $method, $id, $this->flushIndent(), $value); } @@ -305,6 +308,22 @@ class Compiler { return preg_replace("/\n(\t\t)?/", "\n".str_repeat("\t", $bonus), $text); } + const DEFAULT_ESCAPE = 'htmlspecialchars(%s, ENT_COMPAT, %s)'; + const CUSTOM_ESCAPE = 'call_user_func($this->mustache->getEscape(), %s)'; + + /** + * Get the current escaper. + * + * @return string Either a custom callback, or an inline call to `htmlspecialchars` + */ + private function getEscape($value = '$value') { + if ($this->customEscape) { + return sprintf(self::CUSTOM_ESCAPE, $value); + } else { + return sprintf(self::DEFAULT_ESCAPE, $value, var_export($this->charset, true)); + } + } + /** * Select the appropriate Context `find` method for a given $id. * diff --git a/src/Mustache/Mustache.php b/src/Mustache/Mustache.php index 68ecd2a..7a8e61e 100644 --- a/src/Mustache/Mustache.php +++ b/src/Mustache/Mustache.php @@ -40,6 +40,7 @@ class Mustache { private $loader; private $partialsLoader; private $helpers; + private $escape; private $charset = 'UTF-8'; /** @@ -71,6 +72,11 @@ class Mustache { * // do something translatey here... * }), * + * // An 'escape' callback, responsible for escaping double-mustache variables. + * 'escape' => function($value) { + * return htmlspecialchars($buffer, ENT_COMPAT, 'UTF-8'); + * }, + * * // character set for `htmlspecialchars`. Defaults to 'UTF-8' * 'charset' => 'ISO-8859-1', * ); @@ -102,6 +108,14 @@ class Mustache { $this->setHelpers($options['helpers']); } + if (isset($options['escape'])) { + if (!is_callable($options['escape'])) { + throw new \InvalidArgumentException('Mustache Constructor "escape" option must be callable'); + } + + $this->escape = $options['escape']; + } + if (isset($options['charset'])) { $this->charset = $options['charset']; } @@ -124,6 +138,15 @@ class Mustache { return $this->loadTemplate($template)->render($data); } + /** + * Get the current Mustache escape callback. + * + * @return mixed Callable or null + */ + public function getEscape() { + return $this->escape; + } + /** * Get the current Mustache character set. * @@ -366,7 +389,13 @@ class Mustache { * @return string Mustache Template class name */ public function getTemplateClassName($source) { - return $this->templateClassPrefix . md5(self::VERSION . ':' . $source); + return $this->templateClassPrefix . md5(sprintf( + 'version:%s,escape:%s,charset:%s,source:%s', + self::VERSION, + isset($this->escape) ? 'custom' : 'default', + $this->charset, + $source + )); } /** @@ -482,7 +511,10 @@ class Mustache { * @return string generated Mustache template class code */ private function compile($source) { - return $this->getCompiler()->compile($source, $this->parse($source), $this->getTemplateClassName($source)); + $tree = $this->parse($source); + $name = $this->getTemplateClassName($source); + + return $this->getCompiler()->compile($source, $tree, $name, isset($this->escape), $this->charset); } /** diff --git a/test/Mustache/Test/CompilerTest.php b/test/Mustache/Test/CompilerTest.php index b53879b..266b4cf 100644 --- a/test/Mustache/Test/CompilerTest.php +++ b/test/Mustache/Test/CompilerTest.php @@ -22,10 +22,10 @@ class CompilerTest extends \PHPUnit_Framework_TestCase { /** * @dataProvider getCompileValues */ - public function testCompile($source, array $tree, $name, $expected) { + public function testCompile($source, array $tree, $name, $customEscaper, $charset, $expected) { $compiler = new Compiler; - $compiled = $compiler->compile($source, $tree, $name); + $compiled = $compiler->compile($source, $tree, $name, $customEscaper, $charset); foreach ($expected as $contains) { $this->assertContains($contains, $compiled); } @@ -33,14 +33,23 @@ class CompilerTest extends \PHPUnit_Framework_TestCase { public function getCompileValues() { return array( - array('', array(), 'Banana', array( + array('', array(), 'Banana', false, 'ISO-8859-1', array( "\nclass Banana extends \Mustache\Template", + 'return htmlspecialchars($buffer, ENT_COMPAT, \'ISO-8859-1\');', 'return $buffer;', )), - array('', array('TEXT'), 'Monkey', array( + array('', array('TEXT'), 'Monkey', false, 'UTF-8', array( + "\nclass Monkey extends \Mustache\Template", + 'return htmlspecialchars($buffer, ENT_COMPAT, \'UTF-8\');', + '$buffer .= $indent . \'TEXT\';', + 'return $buffer;', + )), + + array('', array('TEXT'), 'Monkey', true, 'ISO-8859-1', array( "\nclass Monkey extends \Mustache\Template", '$buffer .= $indent . \'TEXT\';', + 'return call_user_func($this->mustache->getEscape(), $buffer);', 'return $buffer;', )), @@ -60,14 +69,17 @@ class CompilerTest extends \PHPUnit_Framework_TestCase { "'bar'", ), 'Monkey', + false, + 'UTF-8', array( "\nclass Monkey extends \Mustache\Template", '$buffer .= $indent . \'foo\'', '$buffer .= "\n"', '$value = $context->find(\'name\');', - '$buffer .= htmlspecialchars($value, ENT_COMPAT, $this->mustache->getCharset());', + '$buffer .= htmlspecialchars($value, ENT_COMPAT, \'UTF-8\');', '$value = $context->last();', '$buffer .= \'\\\'bar\\\'\';', + 'return htmlspecialchars($buffer, ENT_COMPAT, \'UTF-8\');', 'return $buffer;', ) ), diff --git a/test/Mustache/Test/MustacheTest.php b/test/Mustache/Test/MustacheTest.php index 4180bee..53d804f 100644 --- a/test/Mustache/Test/MustacheTest.php +++ b/test/Mustache/Test/MustacheTest.php @@ -47,6 +47,7 @@ class MustacheTest extends \PHPUnit_Framework_TestCase { 'foo' => function() { return 'foo'; }, 'bar' => 'BAR', ), + 'escape' => 'strtoupper', 'charset' => 'ISO-8859-1', )); @@ -54,6 +55,7 @@ class MustacheTest extends \PHPUnit_Framework_TestCase { $this->assertSame($partialsLoader, $mustache->getPartialsLoader()); $this->assertEquals('{{ foo }}', $partialsLoader->load('foo')); $this->assertContains('__whot__', $mustache->getTemplateClassName('{{ foo }}')); + $this->assertEquals('strtoupper', $mustache->getEscape()); $this->assertEquals('ISO-8859-1', $mustache->getCharset()); $this->assertTrue($mustache->hasHelper('foo')); $this->assertTrue($mustache->hasHelper('bar')); @@ -127,6 +129,21 @@ class MustacheTest extends \PHPUnit_Framework_TestCase { $this->assertContains("\nclass $className extends \Mustache\Template", file_get_contents($fileName)); } + /** + * @expectedException \InvalidArgumentException + * @dataProvider getBadEscapers + */ + public function testNonCallableEscapeThrowsException($escape) { + new Mustache(array('escape' => $escape)); + } + + public function getBadEscapers() { + return array( + array('nothing'), + array('foo', 'bar'), + ); + } + /** * @group functional * @expectedException \RuntimeException