From 5a29835cd12f4965066d9d7fc2a2d6bf801afbd3 Mon Sep 17 00:00:00 2001 From: Arthur Taylor Date: Fri, 6 Jun 2025 11:41:24 +0200 Subject: [PATCH] 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 --- src/HtmlParser.php | 22 +++++++++ src/Templating.php | 8 +++- tests/integration/FixtureTest.php | 34 ++++++++++++- .../fixture/components/subcomponent.vue | 15 ++++++ tests/integration/fixture/spc_test.html | 14 ++++++ ...populate_fixtures_with_expected_results.js | 48 +++++++++++++++++-- 6 files changed, 134 insertions(+), 7 deletions(-) create mode 100644 tests/integration/fixture/components/subcomponent.vue create mode 100644 tests/integration/fixture/spc_test.html diff --git a/src/HtmlParser.php b/src/HtmlParser.php index acd9e89..1c6ec76 100644 --- a/src/HtmlParser.php +++ b/src/HtmlParser.php @@ -6,6 +6,7 @@ use DOMDocument; use DOMElement; +use DOMXPath; use Exception; use LibXMLError; @@ -14,6 +15,12 @@ */ class HtmlParser { + private array $components; + + public function __construct( array $components = [] ) { + $this->components = $components; + } + /** * Parse the given HTML string into a DOM document. */ @@ -46,9 +53,24 @@ public function parseHtml( string $html ): DOMDocument { //TODO Throw an exception } + $this->substituteComponents( $document ); + return $document; } + private function substituteComponents( DOMDocument $document ) { + $xpath = new DOMXPath( $document ); + foreach ( $this->components as $componentName => $template ) { + $entries = $xpath->query( '//' . $componentName ); + foreach ( $entries as $entry ) { + $templateRoot = $this->getBodyElement( $this->parseHtml( $template ) )->firstChild; + $templateNode = $document->importNode( $templateRoot, true ); + $entry->parentNode->append( $templateNode ); + $entry->remove(); + } + } + } + /** * Get the root node of the template represented by the given document. */ diff --git a/src/Templating.php b/src/Templating.php index a7d3deb..e64858e 100644 --- a/src/Templating.php +++ b/src/Templating.php @@ -4,6 +4,12 @@ class Templating { + private array $components; + + public function __construct( array $componentTemplates = [] ) { + $this->components = $componentTemplates; + } + /** * @param string $template * @param array $data @@ -12,7 +18,7 @@ class Templating { * @return string */ public function render( $template, array $data, array $methods = [] ) { - $htmlParser = new HtmlParser(); + $htmlParser = new HtmlParser( $this->components ); $document = $htmlParser->parseHtml( $template ); $rootNode = $htmlParser->getRootNode( $document ); $component = new Component( $rootNode, $methods ); diff --git a/tests/integration/FixtureTest.php b/tests/integration/FixtureTest.php index b61b6af..bb4d336 100644 --- a/tests/integration/FixtureTest.php +++ b/tests/integration/FixtureTest.php @@ -18,7 +18,8 @@ class FixtureTest extends TestCase { * @dataProvider provideFixtures */ public function testPhpRenderingEqualsVueJsRendering( $template, array $data, $expectedResult ) { - $templating = new Templating(); + $components = $this->loadFixtureComponents(); + $templating = new Templating( $components ); $methods = [ 'message' => 'strval', 'directionality' => function () { @@ -31,6 +32,31 @@ public function testPhpRenderingEqualsVueJsRendering( $template, array $data, $e $this->assertEqualHtml( $expectedResult, $result ); } + public function loadFixtureComponents() { + $componentDir = __DIR__ . '/fixture/components'; + + $components = []; + /** @var DirectoryIterator $fileInfo */ + foreach ( new DirectoryIterator( $componentDir ) as $fileInfo ) { + if ( $fileInfo->isDot() || $fileInfo->isDir() ) { + continue; + } + + $document = new DOMDocument(); + // Ignore all warnings issued by DOMDocument when parsing + // as soon as VueJs template is not actually a "valid" HTML + /** @noinspection UsageOfSilenceOperatorInspection */ + // @codingStandardsIgnoreLine + @$document->loadHTMLFile( $fileInfo->getPathname() ); + + $componentName = $this->getAttribute( $document, 'template', 'component-name' ); + $template = $this->getContents( $document, 'template' ); + + $components[$componentName] = $template; + } + return $components; + } + public function provideFixtures() { $fixtureDir = __DIR__ . '/fixture'; @@ -38,7 +64,7 @@ public function provideFixtures() { /** @var DirectoryIterator $fileInfo */ foreach ( new DirectoryIterator( $fixtureDir ) as $fileInfo ) { - if ( $fileInfo->isDot() ) { + if ( $fileInfo->isDot() || $fileInfo->isDir() ) { continue; } @@ -67,6 +93,10 @@ public function provideFixtures() { return $cases; } + private function getAttribute( DOMDocument $document, $elementId, $attributeId ) { + return $document->getElementById( $elementId )->getAttribute( $attributeId ); + } + /** * @param DOMDocument $document * @param string $elementId diff --git a/tests/integration/fixture/components/subcomponent.vue b/tests/integration/fixture/components/subcomponent.vue new file mode 100644 index 0000000..a133632 --- /dev/null +++ b/tests/integration/fixture/components/subcomponent.vue @@ -0,0 +1,15 @@ + + + \ No newline at end of file diff --git a/tests/integration/fixture/spc_test.html b/tests/integration/fixture/spc_test.html new file mode 100644 index 0000000..5c3ae76 --- /dev/null +++ b/tests/integration/fixture/spc_test.html @@ -0,0 +1,14 @@ + + + +
+ +

Surrounding HTML

Subcomponent!

+
diff --git a/tests/integration/populate_fixtures_with_expected_results.js b/tests/integration/populate_fixtures_with_expected_results.js index c52783e..4f95dc3 100644 --- a/tests/integration/populate_fixtures_with_expected_results.js +++ b/tests/integration/populate_fixtures_with_expected_results.js @@ -4,6 +4,7 @@ const { renderToString } = require( 'vue/server-renderer' ); const fs = require( 'fs' ); const fixtureDir = __dirname + '/fixture'; +const componentDir = fixtureDir + '/components'; const generatedComment = ''; const defaultMethods = { @@ -13,6 +14,20 @@ const defaultMethods = { } }; +const components = {}; + +readComponentDir().then( function ( files ) { + files.forEach( function ( fileName ) { + var filePath = componentDir + '/' + fileName; + readFile( filePath ) + .then( extractComponentTemplate ) + .then( function ( componentData ) { + console.log( 'loaded component ' + fileName + ' : ' + componentData.componentName ); + components[componentData.componentName] = componentData.template; + } ) + } ); +} ); + readFixtureDir().then( function ( files ) { files.forEach( function ( fileName ) { var filePath = fixtureDir + '/' + fileName; @@ -27,10 +42,24 @@ readFixtureDir().then( function ( files ) { } ); } ); +function getTemplateHtml( templateElement ) { + return cheerio.load( templateElement.html() ).html( { decodeEntities: false } ).trim(); +} + +function extractComponentTemplate( html ) { + const $ = cheerio.load( html ); + const templateRoot = $( '#template' ); + + const componentName = templateRoot.attr( 'component-name' ); + console.log("Found component with name " + componentName); + const template = getTemplateHtml( templateRoot ); + return { componentName: componentName, template }; +} + function extractDataFromFixture( html ) { const $ = cheerio.load( html ); - const template = cheerio.load( $( '#template' ).html() ).html( { decodeEntities: false } ).trim(); + const template = getTemplateHtml( $( '#template' ) ); const data = JSON.parse( $( '#data' ).html() ); return { template: template, data: data }; @@ -42,6 +71,9 @@ function renderTemplate( fixtureData ) { data: () => fixtureData.data, methods: defaultMethods } ); + for ( [ componentName, template ] of Object.entries(components) ) { + app.component( componentName, { template } ); + }; return renderToString( app ); } @@ -80,18 +112,26 @@ function saveFile( filePath, contents ) { } ); } -function readFixtureDir() { +function readDirFiles( dirPath ) { return new Promise( function ( resolve, reject ) { - fs.readdir( fixtureDir, function ( err, items ) { + fs.readdir( dirPath, { withFileTypes: true }, function ( err, items ) { if ( err ) { reject( err ) } else { - resolve( items ); + resolve( items.filter((item) => item.isFile()).map((item) => item.name) ); } } ); } ); } +function readComponentDir() { + return readDirFiles( componentDir ); +} + +function readFixtureDir() { + return readDirFiles( fixtureDir ); +} + function saveResultToFile(filePath, renderResult) { readFile( filePath ).then( function ( html ) { const $ = cheerio.load( html );