Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/App.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

declare( strict_types = 1 );

namespace WMDE\VueJsTemplating;

use DOMElement;
use Exception;
use WMDE\VueJsTemplating\JsParsing\BasicJsExpressionParser;
use WMDE\VueJsTemplating\JsParsing\CachingExpressionParser;
use WMDE\VueJsTemplating\JsParsing\JsExpressionParser;

class App {

/** @var HtmlParser */
private $htmlParser;

/** @var JsExpressionParser */
private $expressionParser;

/** @var (Component|string|callable)[] */
private $components = [];

/**
* @param callable[] $methods The available methods.
* The key is the method name, the value is the corresponding callable.
*/
public function __construct( array $methods ) {
$this->htmlParser = new HtmlParser();
$this->expressionParser = new CachingExpressionParser( new BasicJsExpressionParser( $methods ) );
}

/**
* Register the template for a component.
*
* @param string $name The component name.
* @param string|callable $template Either the template HTML as a string,
* or a callable that will return the template HTML as a string when called with no arguments.
* @return void
*/
public function registerComponentTemplate( string $name, $template ): void {
$this->components[$name] = $template;
}

public function evaluateExpression( string $expression, array $data ) {
return $this->expressionParser->parse( $expression )
->evaluate( $data );
}

public function renderComponent( string $componentName, array $data ): string {
$rendered = $this->renderComponentToDOM( $componentName, $data );
return $rendered->ownerDocument->saveHTML( $rendered );
}

public function renderComponentToDOM( string $componentName, array $data ): DOMElement {
return $this->getComponent( $componentName )
->render( $data );
}

private function getComponent( string $componentName ): Component {
$component = $this->components[$componentName] ?? null;
if ( $component === null ) {
throw new Exception( "Unknown component: $componentName" );
}

if ( !( $component instanceof Component ) ) {
if ( is_callable( $component ) ) {
$html = $component();
} else {
$html = $component;
}
/** @var string $html */
$document = $this->htmlParser->parseHtml( $html );
$rootNode = $this->htmlParser->getRootNode( $document );
$component = new Component( $rootNode, $this );
$this->components[$componentName] = $component;
}

return $component;
}

}
92 changes: 50 additions & 42 deletions src/Component.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,6 @@
use DOMNode;
use DOMNodeList;
use DOMText;
use WMDE\VueJsTemplating\JsParsing\BasicJsExpressionParser;
use WMDE\VueJsTemplating\JsParsing\CachingExpressionParser;
use WMDE\VueJsTemplating\JsParsing\JsExpressionParser;

class Component {

Expand All @@ -20,31 +17,28 @@ class Component {
private $rootNode;

/**
* @var JsExpressionParser
* @var DOMNode An arbitrary node to reparent cloned root nodes too,
* so that they can still have a parent node.
* (This is required for {@link self::isRemovedFromTheDom()}.)
*/
private $expressionParser;
private $cloneOwner;

/**
* @param DOMElement $rootNode
* @param callable[] $methods
*/
public function __construct( DOMElement $rootNode, array $methods ) {
/** @var App */
private $app;

public function __construct( DOMElement $rootNode, App $app ) {
$this->rootNode = $rootNode;
$this->expressionParser = new CachingExpressionParser( new BasicJsExpressionParser( $methods ) );
}
$this->app = $app;

/**
* Note: this method is not currently safe to call repeatedly
* (the internal root node is modified in-place).
*
* @param array $data
*
* @return string HTML
*/
public function render( array $data ) {
$this->handleNode( $this->rootNode, $data );
$this->cloneOwner = $rootNode->ownerDocument->documentElement;
}

return $this->rootNode->ownerDocument->saveHTML( $this->rootNode );
public function render( array $data ): DOMElement {
$rootNode = $this->rootNode->cloneNode( true );
$this->cloneOwner->appendChild( $rootNode );
$this->handleNode( $rootNode, $data );
$this->cloneOwner->removeChild( $rootNode );
return $rootNode;
}

/**
Expand All @@ -60,11 +54,13 @@ private function handleNode( DOMNode $node, array $data ) {
$this->handleRawHtml( $node, $data );

if ( !$this->isRemovedFromTheDom( $node ) ) {
$this->handleAttributeBinding( $node, $data );
$this->handleIf( $node->childNodes, $data );
if ( !$this->handleComponent( $node, $data ) ) {
$this->handleAttributeBinding( $node, $data );
$this->handleIf( $node->childNodes, $data );

foreach ( iterator_to_array( $node->childNodes ) as $childNode ) {
$this->handleNode( $childNode, $data );
foreach ( iterator_to_array( $node->childNodes ) as $childNode ) {
$this->handleNode( $childNode, $data );
}
}
}
}
Expand Down Expand Up @@ -99,8 +95,7 @@ private function replaceMustacheVariables( DOMNode $node, array $data ) {
preg_match_all( $regex, $text, $matches );

foreach ( $matches['expression'] as $index => $expression ) {
$value = $this->expressionParser->parse( $expression )
->evaluate( $data );
$value = $this->app->evaluateExpression( $expression, $data );

$text = str_replace( $matches[0][$index], $value, $text );
}
Expand All @@ -112,15 +107,38 @@ private function replaceMustacheVariables( DOMNode $node, array $data ) {
}
}

/** @return bool true if it was a component, false otherwise */
private function handleComponent( DOMElement $node, array $data ): bool {
if ( strpos( $node->tagName, '-' ) === false ) {
return false;
}
$componentName = $node->tagName;

$componentData = [];
foreach ( $node->attributes as $attribute ) {
if ( str_starts_with( $attribute->name, ':' ) ) { // TODO also v-bind: ?
$name = substr( $attribute->name, 1 );
$value = $this->app->evaluateExpression( $attribute->value, $data );
} else {
$name = $attribute->name;
$value = $attribute->value;
}
$componentData[$name] = $value;
}
$rendered = $this->app->renderComponentToDOM( $componentName, $componentData );
// TODO use adoptNode() instead of importNode() in PHP 8.3+ (see php-src commit ed6df1f0ad)
$node->replaceWith( $node->ownerDocument->importNode( $rendered, true ) );
return true;
}

private function handleAttributeBinding( DOMElement $node, array $data ) {
/** @var DOMAttr $attribute */
foreach ( iterator_to_array( $node->attributes ) as $attribute ) {
if ( !str_starts_with( $attribute->name, ':' ) ) {
continue;
}

$value = $this->expressionParser->parse( $attribute->value )
->evaluate( $data );
$value = $this->app->evaluateExpression( $attribute->value, $data );

$name = substr( $attribute->name, 1 );
if ( is_bool( $value ) ) {
Expand Down Expand Up @@ -151,7 +169,7 @@ private function handleIf( DOMNodeList $nodes, array $data ) {
if ( $node->hasAttribute( 'v-if' ) ) {
$conditionString = $node->getAttribute( 'v-if' );
$node->removeAttribute( 'v-if' );
$condition = $this->evaluateExpression( $conditionString, $data );
$condition = $this->app->evaluateExpression( $conditionString, $data );

if ( !$condition ) {
$nodesToRemove[] = $node;
Expand Down Expand Up @@ -219,16 +237,6 @@ private function handleRawHtml( DOMNode $node, array $data ) {
}
}

/**
* @param string $expression
* @param array $data
*
* @return bool
*/
private function evaluateExpression( $expression, array $data ) {
return $this->expressionParser->parse( $expression )->evaluate( $data );
}

private function removeNode( DOMElement $node ) {
$node->parentNode->removeChild( $node );
}
Expand Down
6 changes: 4 additions & 2 deletions src/HtmlParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ public function parseHtml( string $html ): DOMDocument {

$exception = null;
foreach ( $errors as $error ) {
if ( str_starts_with( $error->message, 'Tag template invalid' ) ) {
$msg = $error->message;
if ( str_starts_with( $msg, 'Tag ' ) && str_ends_with( $msg, " invalid\n" ) ) {
// discard "Tag xyz invalid" messages from libxml2 < 2.14.0(?)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oof...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it’s annoying that it doesn’t happen for me locally, only in CI :S

continue;
}
$exception = new Exception( $error->message, $error->code, $exception );
$exception = new Exception( $msg, $error->code, $exception );
}
if ( $exception !== null ) {
throw $exception;
Expand Down
8 changes: 3 additions & 5 deletions src/Templating.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,9 @@ class Templating {
* @return string
*/
public function render( $template, array $data, array $methods = [] ) {
$htmlParser = new HtmlParser();
$document = $htmlParser->parseHtml( $template );
$rootNode = $htmlParser->getRootNode( $document );
$component = new Component( $rootNode, $methods );
return $component->render( $data );
$app = new App( $methods );
$app->registerComponentTemplate( 'root', $template );
return $app->renderComponent( 'root', $data );
}

}
64 changes: 64 additions & 0 deletions tests/php/AppTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

declare( strict_types = 1 );

namespace WMDE\VueJsTemplating\Test;

use PHPUnit\Framework\TestCase;
use WMDE\VueJsTemplating\App;

/**
* @covers \WMDE\VueJsTemplating\App
* @covers \WMDE\VueJsTemplating\Component
*/
class AppTest extends TestCase {

public function testAppRenderedTwice(): void {
$app = new App( [] );
$app->registerComponentTemplate( 'root', '<p>{{ text }}</p>' );

$result1 = $app->renderComponent( 'root', [ 'text' => 'text 1' ] );
$this->assertSame( '<p>text 1</p>', $result1 );

$result2 = $app->renderComponent( 'root', [ 'text' => 'text 2' ] );
$this->assertSame( '<p>text 2</p>', $result2 );
}

public function testAppInitsComponentLazily(): void {
$app = new App( [] );
$called = false;
$app->registerComponentTemplate( 'root', function () use ( &$called ) {
$called = true;
return '<p>{{ text }}</p>';
} );

$this->assertFalse( $called );
$result = $app->renderComponent( 'root', [ 'text' => 'TEXT' ] );
$this->assertSame( '<p>TEXT</p>', $result );
$this->assertTrue( $called );
}

public function testNestedComponents(): void {
$app = new App( [] );
$app->registerComponentTemplate( 'root', '<div><x-a :a="rootVar"></x-a></div>' );
$app->registerComponentTemplate( 'x-a', '<p><x-b :b="a"></x-b></p>' );
$app->registerComponentTemplate( 'x-b', '<span>{{ b }}</span>' );

$result = $app->renderComponent( 'root', [ 'rootVar' => 'text' ] );

$this->assertSame( '<div><p><span>text</span></p></div>', $result );
}

public function testNestedComponentObjectProp(): void {
$app = new App( [] );
$app->registerComponentTemplate( 'root', '<div><x-a :obj="rootObj"></x-a></div>' );
$app->registerComponentTemplate( 'x-a', '<p>obj = { a: {{ obj.a }}, b: {{ obj.b }} }</p>' );

$result = $app->renderComponent( 'root', [
'rootObj' => [ 'a' => 'A', 'b' => 'B' ],
] );

$this->assertSame( '<div><p>obj = { a: A, b: B }</p></div>', $result );
}

}