Skip to content

Commit 4e6e484

Browse files
authored
Merge pull request #35 from wmde/app
Sub-component support
2 parents 1255b0f + 23cbbc7 commit 4e6e484

File tree

5 files changed

+203
-49
lines changed

5 files changed

+203
-49
lines changed

src/App.php

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace WMDE\VueJsTemplating;
6+
7+
use DOMElement;
8+
use Exception;
9+
use WMDE\VueJsTemplating\JsParsing\BasicJsExpressionParser;
10+
use WMDE\VueJsTemplating\JsParsing\CachingExpressionParser;
11+
use WMDE\VueJsTemplating\JsParsing\JsExpressionParser;
12+
13+
class App {
14+
15+
/** @var HtmlParser */
16+
private $htmlParser;
17+
18+
/** @var JsExpressionParser */
19+
private $expressionParser;
20+
21+
/** @var (Component|string|callable)[] */
22+
private $components = [];
23+
24+
/**
25+
* @param callable[] $methods The available methods.
26+
* The key is the method name, the value is the corresponding callable.
27+
*/
28+
public function __construct( array $methods ) {
29+
$this->htmlParser = new HtmlParser();
30+
$this->expressionParser = new CachingExpressionParser( new BasicJsExpressionParser( $methods ) );
31+
}
32+
33+
/**
34+
* Register the template for a component.
35+
*
36+
* @param string $name The component name.
37+
* @param string|callable $template Either the template HTML as a string,
38+
* or a callable that will return the template HTML as a string when called with no arguments.
39+
* @return void
40+
*/
41+
public function registerComponentTemplate( string $name, $template ): void {
42+
$this->components[$name] = $template;
43+
}
44+
45+
public function evaluateExpression( string $expression, array $data ) {
46+
return $this->expressionParser->parse( $expression )
47+
->evaluate( $data );
48+
}
49+
50+
public function renderComponent( string $componentName, array $data ): string {
51+
$rendered = $this->renderComponentToDOM( $componentName, $data );
52+
return $rendered->ownerDocument->saveHTML( $rendered );
53+
}
54+
55+
public function renderComponentToDOM( string $componentName, array $data ): DOMElement {
56+
return $this->getComponent( $componentName )
57+
->render( $data );
58+
}
59+
60+
private function getComponent( string $componentName ): Component {
61+
$component = $this->components[$componentName] ?? null;
62+
if ( $component === null ) {
63+
throw new Exception( "Unknown component: $componentName" );
64+
}
65+
66+
if ( !( $component instanceof Component ) ) {
67+
if ( is_callable( $component ) ) {
68+
$html = $component();
69+
} else {
70+
$html = $component;
71+
}
72+
/** @var string $html */
73+
$document = $this->htmlParser->parseHtml( $html );
74+
$rootNode = $this->htmlParser->getRootNode( $document );
75+
$component = new Component( $rootNode, $this );
76+
$this->components[$componentName] = $component;
77+
}
78+
79+
return $component;
80+
}
81+
82+
}

src/Component.php

Lines changed: 50 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,6 @@
88
use DOMNode;
99
use DOMNodeList;
1010
use DOMText;
11-
use WMDE\VueJsTemplating\JsParsing\BasicJsExpressionParser;
12-
use WMDE\VueJsTemplating\JsParsing\CachingExpressionParser;
13-
use WMDE\VueJsTemplating\JsParsing\JsExpressionParser;
1411

1512
class Component {
1613

@@ -20,31 +17,28 @@ class Component {
2017
private $rootNode;
2118

2219
/**
23-
* @var JsExpressionParser
20+
* @var DOMNode An arbitrary node to reparent cloned root nodes too,
21+
* so that they can still have a parent node.
22+
* (This is required for {@link self::isRemovedFromTheDom()}.)
2423
*/
25-
private $expressionParser;
24+
private $cloneOwner;
2625

27-
/**
28-
* @param DOMElement $rootNode
29-
* @param callable[] $methods
30-
*/
31-
public function __construct( DOMElement $rootNode, array $methods ) {
26+
/** @var App */
27+
private $app;
28+
29+
public function __construct( DOMElement $rootNode, App $app ) {
3230
$this->rootNode = $rootNode;
33-
$this->expressionParser = new CachingExpressionParser( new BasicJsExpressionParser( $methods ) );
34-
}
31+
$this->app = $app;
3532

36-
/**
37-
* Note: this method is not currently safe to call repeatedly
38-
* (the internal root node is modified in-place).
39-
*
40-
* @param array $data
41-
*
42-
* @return string HTML
43-
*/
44-
public function render( array $data ) {
45-
$this->handleNode( $this->rootNode, $data );
33+
$this->cloneOwner = $rootNode->ownerDocument->documentElement;
34+
}
4635

47-
return $this->rootNode->ownerDocument->saveHTML( $this->rootNode );
36+
public function render( array $data ): DOMElement {
37+
$rootNode = $this->rootNode->cloneNode( true );
38+
$this->cloneOwner->appendChild( $rootNode );
39+
$this->handleNode( $rootNode, $data );
40+
$this->cloneOwner->removeChild( $rootNode );
41+
return $rootNode;
4842
}
4943

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

6256
if ( !$this->isRemovedFromTheDom( $node ) ) {
63-
$this->handleAttributeBinding( $node, $data );
64-
$this->handleIf( $node->childNodes, $data );
57+
if ( !$this->handleComponent( $node, $data ) ) {
58+
$this->handleAttributeBinding( $node, $data );
59+
$this->handleIf( $node->childNodes, $data );
6560

66-
foreach ( iterator_to_array( $node->childNodes ) as $childNode ) {
67-
$this->handleNode( $childNode, $data );
61+
foreach ( iterator_to_array( $node->childNodes ) as $childNode ) {
62+
$this->handleNode( $childNode, $data );
63+
}
6864
}
6965
}
7066
}
@@ -99,8 +95,7 @@ private function replaceMustacheVariables( DOMNode $node, array $data ) {
9995
preg_match_all( $regex, $text, $matches );
10096

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

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

110+
/** @return bool true if it was a component, false otherwise */
111+
private function handleComponent( DOMElement $node, array $data ): bool {
112+
if ( strpos( $node->tagName, '-' ) === false ) {
113+
return false;
114+
}
115+
$componentName = $node->tagName;
116+
117+
$componentData = [];
118+
foreach ( $node->attributes as $attribute ) {
119+
if ( str_starts_with( $attribute->name, ':' ) ) { // TODO also v-bind: ?
120+
$name = substr( $attribute->name, 1 );
121+
$value = $this->app->evaluateExpression( $attribute->value, $data );
122+
} else {
123+
$name = $attribute->name;
124+
$value = $attribute->value;
125+
}
126+
$componentData[$name] = $value;
127+
}
128+
$rendered = $this->app->renderComponentToDOM( $componentName, $componentData );
129+
// TODO use adoptNode() instead of importNode() in PHP 8.3+ (see php-src commit ed6df1f0ad)
130+
$node->replaceWith( $node->ownerDocument->importNode( $rendered, true ) );
131+
return true;
132+
}
133+
115134
private function handleAttributeBinding( DOMElement $node, array $data ) {
116135
/** @var DOMAttr $attribute */
117136
foreach ( iterator_to_array( $node->attributes ) as $attribute ) {
118137
if ( !str_starts_with( $attribute->name, ':' ) ) {
119138
continue;
120139
}
121140

122-
$value = $this->expressionParser->parse( $attribute->value )
123-
->evaluate( $data );
141+
$value = $this->app->evaluateExpression( $attribute->value, $data );
124142

125143
$name = substr( $attribute->name, 1 );
126144
if ( is_bool( $value ) ) {
@@ -151,7 +169,7 @@ private function handleIf( DOMNodeList $nodes, array $data ) {
151169
if ( $node->hasAttribute( 'v-if' ) ) {
152170
$conditionString = $node->getAttribute( 'v-if' );
153171
$node->removeAttribute( 'v-if' );
154-
$condition = $this->evaluateExpression( $conditionString, $data );
172+
$condition = $this->app->evaluateExpression( $conditionString, $data );
155173

156174
if ( !$condition ) {
157175
$nodesToRemove[] = $node;
@@ -219,16 +237,6 @@ private function handleRawHtml( DOMNode $node, array $data ) {
219237
}
220238
}
221239

222-
/**
223-
* @param string $expression
224-
* @param array $data
225-
*
226-
* @return bool
227-
*/
228-
private function evaluateExpression( $expression, array $data ) {
229-
return $this->expressionParser->parse( $expression )->evaluate( $data );
230-
}
231-
232240
private function removeNode( DOMElement $node ) {
233241
$node->parentNode->removeChild( $node );
234242
}

src/HtmlParser.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,12 @@ public function parseHtml( string $html ): DOMDocument {
4343

4444
$exception = null;
4545
foreach ( $errors as $error ) {
46-
if ( str_starts_with( $error->message, 'Tag template invalid' ) ) {
46+
$msg = $error->message;
47+
if ( str_starts_with( $msg, 'Tag ' ) && str_ends_with( $msg, " invalid\n" ) ) {
48+
// discard "Tag xyz invalid" messages from libxml2 < 2.14.0(?)
4749
continue;
4850
}
49-
$exception = new Exception( $error->message, $error->code, $exception );
51+
$exception = new Exception( $msg, $error->code, $exception );
5052
}
5153
if ( $exception !== null ) {
5254
throw $exception;

src/Templating.php

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,9 @@ class Templating {
1212
* @return string
1313
*/
1414
public function render( $template, array $data, array $methods = [] ) {
15-
$htmlParser = new HtmlParser();
16-
$document = $htmlParser->parseHtml( $template );
17-
$rootNode = $htmlParser->getRootNode( $document );
18-
$component = new Component( $rootNode, $methods );
19-
return $component->render( $data );
15+
$app = new App( $methods );
16+
$app->registerComponentTemplate( 'root', $template );
17+
return $app->renderComponent( 'root', $data );
2018
}
2119

2220
}

tests/php/AppTest.php

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
declare( strict_types = 1 );
4+
5+
namespace WMDE\VueJsTemplating\Test;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use WMDE\VueJsTemplating\App;
9+
10+
/**
11+
* @covers \WMDE\VueJsTemplating\App
12+
* @covers \WMDE\VueJsTemplating\Component
13+
*/
14+
class AppTest extends TestCase {
15+
16+
public function testAppRenderedTwice(): void {
17+
$app = new App( [] );
18+
$app->registerComponentTemplate( 'root', '<p>{{ text }}</p>' );
19+
20+
$result1 = $app->renderComponent( 'root', [ 'text' => 'text 1' ] );
21+
$this->assertSame( '<p>text 1</p>', $result1 );
22+
23+
$result2 = $app->renderComponent( 'root', [ 'text' => 'text 2' ] );
24+
$this->assertSame( '<p>text 2</p>', $result2 );
25+
}
26+
27+
public function testAppInitsComponentLazily(): void {
28+
$app = new App( [] );
29+
$called = false;
30+
$app->registerComponentTemplate( 'root', function () use ( &$called ) {
31+
$called = true;
32+
return '<p>{{ text }}</p>';
33+
} );
34+
35+
$this->assertFalse( $called );
36+
$result = $app->renderComponent( 'root', [ 'text' => 'TEXT' ] );
37+
$this->assertSame( '<p>TEXT</p>', $result );
38+
$this->assertTrue( $called );
39+
}
40+
41+
public function testNestedComponents(): void {
42+
$app = new App( [] );
43+
$app->registerComponentTemplate( 'root', '<div><x-a :a="rootVar"></x-a></div>' );
44+
$app->registerComponentTemplate( 'x-a', '<p><x-b :b="a"></x-b></p>' );
45+
$app->registerComponentTemplate( 'x-b', '<span>{{ b }}</span>' );
46+
47+
$result = $app->renderComponent( 'root', [ 'rootVar' => 'text' ] );
48+
49+
$this->assertSame( '<div><p><span>text</span></p></div>', $result );
50+
}
51+
52+
public function testNestedComponentObjectProp(): void {
53+
$app = new App( [] );
54+
$app->registerComponentTemplate( 'root', '<div><x-a :obj="rootObj"></x-a></div>' );
55+
$app->registerComponentTemplate( 'x-a', '<p>obj = { a: {{ obj.a }}, b: {{ obj.b }} }</p>' );
56+
57+
$result = $app->renderComponent( 'root', [
58+
'rootObj' => [ 'a' => 'A', 'b' => 'B' ],
59+
] );
60+
61+
$this->assertSame( '<div><p>obj = { a: A, b: B }</p></div>', $result );
62+
}
63+
64+
}

0 commit comments

Comments
 (0)