Skip to content

Commit 5a29835

Browse files
committed
Added initial support for processing custom components in templates
We will very probably need support for rendering our own custom Vue components in our server-side rendering. Add an initial sketch of what that might look like. Bug: T395802
1 parent 158a0ff commit 5a29835

File tree

6 files changed

+134
-7
lines changed

6 files changed

+134
-7
lines changed

src/HtmlParser.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use DOMDocument;
88
use DOMElement;
9+
use DOMXPath;
910
use Exception;
1011
use LibXMLError;
1112

@@ -14,6 +15,12 @@
1415
*/
1516
class HtmlParser {
1617

18+
private array $components;
19+
20+
public function __construct( array $components = [] ) {
21+
$this->components = $components;
22+
}
23+
1724
/**
1825
* Parse the given HTML string into a DOM document.
1926
*/
@@ -46,9 +53,24 @@ public function parseHtml( string $html ): DOMDocument {
4653
//TODO Throw an exception
4754
}
4855

56+
$this->substituteComponents( $document );
57+
4958
return $document;
5059
}
5160

61+
private function substituteComponents( DOMDocument $document ) {
62+
$xpath = new DOMXPath( $document );
63+
foreach ( $this->components as $componentName => $template ) {
64+
$entries = $xpath->query( '//' . $componentName );
65+
foreach ( $entries as $entry ) {
66+
$templateRoot = $this->getBodyElement( $this->parseHtml( $template ) )->firstChild;
67+
$templateNode = $document->importNode( $templateRoot, true );
68+
$entry->parentNode->append( $templateNode );
69+
$entry->remove();
70+
}
71+
}
72+
}
73+
5274
/**
5375
* Get the root node of the template represented by the given document.
5476
*/

src/Templating.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@
44

55
class Templating {
66

7+
private array $components;
8+
9+
public function __construct( array $componentTemplates = [] ) {
10+
$this->components = $componentTemplates;
11+
}
12+
713
/**
814
* @param string $template
915
* @param array $data
@@ -12,7 +18,7 @@ class Templating {
1218
* @return string
1319
*/
1420
public function render( $template, array $data, array $methods = [] ) {
15-
$htmlParser = new HtmlParser();
21+
$htmlParser = new HtmlParser( $this->components );
1622
$document = $htmlParser->parseHtml( $template );
1723
$rootNode = $htmlParser->getRootNode( $document );
1824
$component = new Component( $rootNode, $methods );

tests/integration/FixtureTest.php

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ class FixtureTest extends TestCase {
1818
* @dataProvider provideFixtures
1919
*/
2020
public function testPhpRenderingEqualsVueJsRendering( $template, array $data, $expectedResult ) {
21-
$templating = new Templating();
21+
$components = $this->loadFixtureComponents();
22+
$templating = new Templating( $components );
2223
$methods = [
2324
'message' => 'strval',
2425
'directionality' => function () {
@@ -31,14 +32,39 @@ public function testPhpRenderingEqualsVueJsRendering( $template, array $data, $e
3132
$this->assertEqualHtml( $expectedResult, $result );
3233
}
3334

35+
public function loadFixtureComponents() {
36+
$componentDir = __DIR__ . '/fixture/components';
37+
38+
$components = [];
39+
/** @var DirectoryIterator $fileInfo */
40+
foreach ( new DirectoryIterator( $componentDir ) as $fileInfo ) {
41+
if ( $fileInfo->isDot() || $fileInfo->isDir() ) {
42+
continue;
43+
}
44+
45+
$document = new DOMDocument();
46+
// Ignore all warnings issued by DOMDocument when parsing
47+
// as soon as VueJs template is not actually a "valid" HTML
48+
/** @noinspection UsageOfSilenceOperatorInspection */
49+
// @codingStandardsIgnoreLine
50+
@$document->loadHTMLFile( $fileInfo->getPathname() );
51+
52+
$componentName = $this->getAttribute( $document, 'template', 'component-name' );
53+
$template = $this->getContents( $document, 'template' );
54+
55+
$components[$componentName] = $template;
56+
}
57+
return $components;
58+
}
59+
3460
public function provideFixtures() {
3561
$fixtureDir = __DIR__ . '/fixture';
3662

3763
$cases = [];
3864

3965
/** @var DirectoryIterator $fileInfo */
4066
foreach ( new DirectoryIterator( $fixtureDir ) as $fileInfo ) {
41-
if ( $fileInfo->isDot() ) {
67+
if ( $fileInfo->isDot() || $fileInfo->isDir() ) {
4268
continue;
4369
}
4470

@@ -67,6 +93,10 @@ public function provideFixtures() {
6793
return $cases;
6894
}
6995

96+
private function getAttribute( DOMDocument $document, $elementId, $attributeId ) {
97+
return $document->getElementById( $elementId )->getAttribute( $attributeId );
98+
}
99+
70100
/**
71101
* @param DOMDocument $document
72102
* @param string $elementId
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<template id="template" component-name="subcomponent">
2+
<div><p>Subcomponent!</p></div>
3+
</template>
4+
5+
<script>
6+
const { defineComponent } = require( 'vue' );
7+
8+
// @vue/component
9+
module.exports = exports = defineComponent( {
10+
name: 'SubComponent',
11+
setup() {
12+
return { };
13+
}
14+
} );
15+
</script>
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<template id="template">
2+
<div>
3+
<p>Surrounding HTML</p>
4+
<subcomponent>
5+
</subcomponent></div>
6+
</template>
7+
8+
<script id="data" type="application/json">
9+
{"condition":true , "link":"<a href=\"URL\">link</a>"}
10+
</script>
11+
<div id="result">
12+
<!-- generated by `npm run-script populate-fixtures` -->
13+
<div><p>Surrounding HTML</p><div><p>Subcomponent!</p></div></div>
14+
</div>

tests/integration/populate_fixtures_with_expected_results.js

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ const { renderToString } = require( 'vue/server-renderer' );
44
const fs = require( 'fs' );
55

66
const fixtureDir = __dirname + '/fixture';
7+
const componentDir = fixtureDir + '/components';
78
const generatedComment = '<!-- generated by `npm run-script populate-fixtures` -->';
89

910
const defaultMethods = {
@@ -13,6 +14,20 @@ const defaultMethods = {
1314
}
1415
};
1516

17+
const components = {};
18+
19+
readComponentDir().then( function ( files ) {
20+
files.forEach( function ( fileName ) {
21+
var filePath = componentDir + '/' + fileName;
22+
readFile( filePath )
23+
.then( extractComponentTemplate )
24+
.then( function ( componentData ) {
25+
console.log( 'loaded component ' + fileName + ' : ' + componentData.componentName );
26+
components[componentData.componentName] = componentData.template;
27+
} )
28+
} );
29+
} );
30+
1631
readFixtureDir().then( function ( files ) {
1732
files.forEach( function ( fileName ) {
1833
var filePath = fixtureDir + '/' + fileName;
@@ -27,10 +42,24 @@ readFixtureDir().then( function ( files ) {
2742
} );
2843
} );
2944

45+
function getTemplateHtml( templateElement ) {
46+
return cheerio.load( templateElement.html() ).html( { decodeEntities: false } ).trim();
47+
}
48+
49+
function extractComponentTemplate( html ) {
50+
const $ = cheerio.load( html );
51+
const templateRoot = $( '#template' );
52+
53+
const componentName = templateRoot.attr( 'component-name' );
54+
console.log("Found component with name " + componentName);
55+
const template = getTemplateHtml( templateRoot );
56+
return { componentName: componentName, template };
57+
}
58+
3059
function extractDataFromFixture( html ) {
3160
const $ = cheerio.load( html );
3261

33-
const template = cheerio.load( $( '#template' ).html() ).html( { decodeEntities: false } ).trim();
62+
const template = getTemplateHtml( $( '#template' ) );
3463
const data = JSON.parse( $( '#data' ).html() );
3564

3665
return { template: template, data: data };
@@ -42,6 +71,9 @@ function renderTemplate( fixtureData ) {
4271
data: () => fixtureData.data,
4372
methods: defaultMethods
4473
} );
74+
for ( [ componentName, template ] of Object.entries(components) ) {
75+
app.component( componentName, { template } );
76+
};
4577

4678
return renderToString( app );
4779
}
@@ -80,18 +112,26 @@ function saveFile( filePath, contents ) {
80112
} );
81113
}
82114

83-
function readFixtureDir() {
115+
function readDirFiles( dirPath ) {
84116
return new Promise( function ( resolve, reject ) {
85-
fs.readdir( fixtureDir, function ( err, items ) {
117+
fs.readdir( dirPath, { withFileTypes: true }, function ( err, items ) {
86118
if ( err ) {
87119
reject( err )
88120
} else {
89-
resolve( items );
121+
resolve( items.filter((item) => item.isFile()).map((item) => item.name) );
90122
}
91123
} );
92124
} );
93125
}
94126

127+
function readComponentDir() {
128+
return readDirFiles( componentDir );
129+
}
130+
131+
function readFixtureDir() {
132+
return readDirFiles( fixtureDir );
133+
}
134+
95135
function saveResultToFile(filePath, renderResult) {
96136
readFile( filePath ).then( function ( html ) {
97137
const $ = cheerio.load( html );

0 commit comments

Comments
 (0)