[FEATURE] add templating interface and twig

This commit is contained in:
Marco Stoll 2019-06-24 09:44:09 +02:00
parent ed0d8c5854
commit f247fb692e
10 changed files with 581 additions and 1 deletions

View File

@ -15,7 +15,12 @@
"require": {
"php": ">=7.2",
"fastforward/data-structures": "^1.0.0",
"fastforward/factories": "^1.2.0"
"fastforward/factories": "^1.2.0",
"symfony/config": "~4.3",
"symfony/http-foundation": "~4.3",
"symfony/routing": "~4.3",
"symfony/yaml": "~4.3",
"twig/twig": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^8"

View File

@ -0,0 +1,43 @@
<?php
/**
* Definition of PostRender
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Events\Templating;
use FF\Events\AbstractEvent;
use FF\Services\Templating\RenderedDocument;
/**
* Class PostRender
*
* @package FF\Events\Templating
*/
class PostRender extends AbstractEvent
{
/**
* @var RenderedDocument
*/
protected $doc;
/**
* @param RenderedDocument $doc
*/
public function __construct(RenderedDocument $doc)
{
$this->doc = $doc;
}
/**
* @return RenderedDocument
*/
public function getDoc(): RenderedDocument
{
return $this->doc;
}
}

View File

@ -0,0 +1,58 @@
<?php
/**
* Definition of PreRender
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Events\Templating;
use FF\DataStructures\Record;
use FF\Events\AbstractEvent;
/**
* Class PreForward
*
* @package FF\Events\Templating
*/
class PreRender extends AbstractEvent
{
/**
* @var string
*/
protected $template;
/**
* @var Record
*/
protected $data;
/**
* @param string $template
* @param Record $data
*/
public function __construct(string $template, Record $data)
{
$this->template = $template;
$this->data = $data;
}
/**
* @return string
*/
public function getTemplate(): string
{
return $this->template;
}
/**
* @return Record
*/
public function getData(): Record
{
return $this->data;
}
}

View File

@ -0,0 +1,21 @@
<?php
/**
* Definition of RenderingException
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Templating\Exceptions;
/**
* Class RenderingException
*
* @package FF\Services\Templating\Exceptions
*/
class RenderingException extends \RuntimeException
{
}

View File

@ -0,0 +1,50 @@
<?php
/**
* Definition of RenderedDocument
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Templating;
/**
* Class RenderedDocument
*
* @package FF\Services\Templating
*/
class RenderedDocument
{
/**
* @var string
*/
private $contents;
/**
* @param string $contents
*/
public function __construct(string $contents)
{
$this->contents = $contents;
}
/**
* @return string
*/
public function getContents(): string
{
return $this->contents;
}
/**
* @param string $contents
* @return $this
*/
public function setContents(string $contents)
{
$this->contents = $contents;
return $this;
}
}

View File

@ -0,0 +1,33 @@
<?php
/**
* Definition of TemplateRendererInterface
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Templating;
use FF\Services\Templating\Exceptions\RenderingException;
/**
* Interface TemplateRendererInterface
*
* @package FF\Services\Templating
*/
interface TemplateRendererInterface
{
/**
* Renders a template using the given data
*
* @param string $template
* @param array $data
* @return string
* @throws RenderingException
* @fires Templating\PreRender
* @fires Templating\PostRender
*/
public function render(string $template, array $data): string;
}

View File

@ -0,0 +1,186 @@
<?php
/**
* Definition of TwigRenderer
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Services\Templating;
use FF\DataStructures\Record;
use FF\Services\AbstractService;
use FF\Services\Templating\Exceptions\RenderingException;
use Twig\Environment;
use Twig\Error\Error;
use Twig\Extension\ExtensionInterface;
use Twig\Loader\FilesystemLoader;
use Twig\NodeVisitor\NodeVisitorInterface;
use Twig\RuntimeLoader\RuntimeLoaderInterface;
use Twig\TokenParser\TokenParserInterface;
use Twig\TwigFilter;
use Twig\TwigFunction;
use Twig\TwigTest;
/**
* Class TwigRenderer
*
* Options:
*
* - twig-options : array (default: []) - options to configure twig environment
* - template-dir : string - path to templates folder
* - fire-events : bool (default: false) - whether to fire rendering events
*
* @method void addRuntimeLoader(RuntimeLoaderInterface $loader)
* @method void addExtension(ExtensionInterface $extension)
* @method void addTokenParser(TokenParserInterface $parser)
* @method void addNodeVisitor(NodeVisitorInterface $visitor)
* @method void addFilter(TwigFilter $filter)
* @method void addTest(TwigTest $test)
* @method void addFunction(TwigFunction $function)
* @method void addGlobal(string $name, mixed $value)
*
* @package FF\Services\Templating
*
* @link https://twig.symfony.com/doc/2.x/api.html#environment-options Twig Environment options
* @link https://twig.symfony.com/doc/2.x/advanced.html Extending Twig
*/
class TwigRenderer extends AbstractService implements TemplateRendererInterface
{
/**
* @var Environment
*/
protected $twig;
/**
* @var bool
*/
protected $fireEvents;
/**
* @return Environment
*/
public function getTwig(): Environment
{
return $this->twig;
}
/**
* @param Environment $twig
* @return $this
*/
public function setTwig(Environment $twig)
{
$this->twig = $twig;
return $this;
}
/**
* @return bool
*/
public function getFireEvents(): bool
{
return $this->fireEvents;
}
/**
* @param bool $fireEvents
* @return $this
*/
public function setFireEvents(bool $fireEvents)
{
$this->fireEvents = $fireEvents;
return $this;
}
/**
* Renders a template using the given data
*
* @param string $template
* @param array $data
* @return string
* @throws RenderingException
* @fires Templating\PreRender
* @fires Templating\PostRender
*/
public function render(string $template, array $data): string
{
if ($this->fireEvents) {
// wrap data in object to support data manipulation via event listener
$record = new Record($data);
$this->fire('Templating\PreRender', $template, $record);
$data = $record->getDataAsArray();
}
try {
$contents = $this->twig->render($template, $data);
} catch (Error $e) {
throw new RenderingException($e->getMessage(), $e->getCode(), $e);
}
if ($this->fireEvents) {
// wrap data in object to support data manipulation via event listener
$doc = new RenderedDocument($contents);
$this->fire('Templating\PostRender', $doc);
$contents = $doc->getContents();
}
return $contents;
}
/**
* Magic proxy for the public api of Twig\Environment
*
* Routes the method call to the twig environment instance encapsulated
* within this service.
*
* @param string $name
* @param array $arguments
* @return mixed
* @throws \BadMethodCallException Method is not defined or accessible on Twig\Environment
*/
public function __call(string $name, array $arguments = [])
{
$callable = [$this->twig, $name];
if (!is_callable($callable)) {
// trigger fatal error: unsupported method call
// mimic standard php error message
// Fatal error: Call to undefined method {class}::{method}() in {file} on line {line}
$backTrace = debug_backtrace();
$errorMsg = 'Call to undefined method ' . __CLASS__ . '::' . $name . '() '
. 'in ' . $backTrace[0]['file'] . ' on line ' . $backTrace[0]['line'];
trigger_error($errorMsg, E_USER_ERROR);
}
return call_user_func_array($callable, $arguments);
}
/**
* {@inheritDoc}
*/
protected function initialize(array $options)
{
parent::initialize($options);
$this->fireEvents = $this->getOption('fire-events', false);
$loader = new FilesystemLoader($this->getOption('template-dir'));
$this->twig = new Environment($loader, $this->getOption('twig-options', []));
}
/**
* {@inheritDoc}
*/
protected function validateOptions(array $options, array &$errors): bool
{
if (!isset($options['template-dir']) || empty($options['template-dir'])) {
$errors[] = 'missing or empty mandatory option [template-dir]';
return false;
}
return true;
}
}

View File

@ -0,0 +1,181 @@
<?php
/**
* Definition of TwigRendererTest
*
* @author Marco Stoll <marco@fast-forward-encoding.de>
* @copyright 2019-forever Marco Stoll
* @filesource
*/
declare(strict_types=1);
namespace FF\Tests\Services\Templating;
use FF\Events\AbstractEvent;
use FF\Events\Templating\PostRender;
use FF\Events\Templating\PreRender;
use FF\Factories\ServicesFactory;
use FF\Factories\SF;
use FF\Services\Events\EventBroker;
use FF\Services\Exceptions\ConfigurationException;
use FF\Services\Templating\Exceptions\RenderingException;
use FF\Services\Templating\TwigRenderer;
use PHPUnit\Framework\Error\Error;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\ArrayLoader;
/**
* Test TwigRendererTest
*
* @package FF\Tests
*/
class TwigRendererTest extends TestCase
{
const DEFAULT_OPTIONS = [
'template-dir' => __DIR__ . '/templates'
];
/**
* @var TwigRenderer
*/
protected $uut;
/**
* @var AbstractEvent[]
*/
protected static $lastEvents;
/**
* {@inheritdoc}
*/
public static function setUpBeforeClass(): void
{
SF::setInstance(new ServicesFactory());
/** @var EventBroker $eventBroker */
$eventBroker = SF::i()->get('Events\EventBroker');
// register test listener
$eventBroker
->subscribe([__CLASS__, 'listener'], 'Templating\PreRender')
->subscribe([__CLASS__, 'listener'], 'Templating\PostRender');
}
/**
* {@inheritdoc}
*/
protected function setUp(): void
{
$this->uut = new TwigRenderer(self::DEFAULT_OPTIONS);
self::$lastEvents = [];
}
/**
* Dummy event listener
*
* @param AbstractEvent $event
*/
public static function listener(AbstractEvent $event)
{
self::$lastEvents[get_class($event)] = $event;
}
/**
* Tests the namesake method/feature
*/
public function testInitializeErrorMandatoryOptions()
{
$this->expectException(ConfigurationException::class);
new TwigRenderer();
}
/**
* Tests the namesake method/feature
*/
public function testSetGetTwig()
{
$value = new Environment(new ArrayLoader());
$same = $this->uut->setTwig($value);
$this->assertSame($this->uut, $same);
$this->assertEquals($value, $this->uut->getTwig());
}
/**
* Tests the namesake method/feature
*/
public function testSetGetFireEvents()
{
$value = false;
$same = $this->uut->setFireEvents($value);
$this->assertSame($this->uut, $same);
$this->assertEquals($value, $this->uut->getFireEvents());
}
/**
* Tests the namesake method/feature
*/
public function testRenderWithoutEvents()
{
$doc = $this->uut->setFireEvents(false)
->render('basic.html.twig', ['foo' => 'bar']);
$this->assertEquals('foo: bar', $doc);
$this->assertEmpty(self::$lastEvents);
}
/**
* Tests the namesake method/feature
*/
public function testRenderWithEvents()
{
$doc = $this->uut->setFireEvents(true)
->render('basic.html.twig', ['foo' => 'bar']);
$this->assertEquals('foo: bar', $doc);
$this->assertArrayHasKey(PreRender::class, self::$lastEvents);
$this->assertArrayHasKey(PostRender::class, self::$lastEvents);
}
/**
* Tests the namesake method/feature
*/
public function testRenderErrorLoader()
{
$this->expectException(RenderingException::class);
$this->uut->render('missing.html.twig', []);
}
/**
* Tests the namesake method/feature
*/
public function testRenderErrorSyntax()
{
$this->expectException(RenderingException::class);
$this->uut->render('invalid.html.twig', []);
}
/**
* Tests the namesake method/feature
*/
public function testMagicCall()
{
$this->uut->addGlobal('foo', 'bar');
$doc = $this->uut->render('basic.html.twig', []);
$this->assertEquals('foo: bar', $doc);
}
/**
* Tests the namesake method/feature
*/
public function testMagicCallUnknown()
{
$this->expectException(Error::class);
$this->uut->foo();
}
}

View File

@ -0,0 +1 @@
foo: {{ foo }}

View File

@ -0,0 +1,2 @@
{% for %}
{% endif %}