diff --git a/build.default.json b/build.default.json index e5afe8ee..ba8179df 100644 --- a/build.default.json +++ b/build.default.json @@ -47,15 +47,5 @@ "command": "vendor/bin/sync", "arguments": ["asset/", "www/asset", "--delete", "--symlink"] } - }, - - "data/**/*": { - "require": { - "vendor/bin/sync": "*" - }, - "execute": { - "command": "vendor/bin/sync", - "arguments": ["data/", "www/data", "--delete", "--symlink"] - } } } diff --git a/src/Dispatch/Dispatcher.php b/src/Dispatch/Dispatcher.php index 2639bb4b..81f7f191 100644 --- a/src/Dispatch/Dispatcher.php +++ b/src/Dispatch/Dispatcher.php @@ -403,6 +403,7 @@ public function processResponse( $componentList = $this->viewModelProcessor?->processPartialContent( $this->viewModel, + $this->viewAssembly, ); // TODO: CSRF handling - needs to be done on any POST request. diff --git a/src/Logic/HTMLDocumentProcessor.php b/src/Logic/HTMLDocumentProcessor.php index 9ae5ce2c..662485cf 100644 --- a/src/Logic/HTMLDocumentProcessor.php +++ b/src/Logic/HTMLDocumentProcessor.php @@ -8,6 +8,7 @@ use GT\DomTemplate\PartialExpander; use GT\Routing\Assembly; use GT\Routing\Path\DynamicPath; +use GT\WebEngine\View\HeaderFooterPartialConflictException; class HTMLDocumentProcessor extends ViewModelProcessor { function processDynamicPath( @@ -30,7 +31,16 @@ function processDynamicPath( function processPartialContent( HTMLDocument $model, + ?Assembly $viewAssembly = null, ):LogicAssemblyComponentList { + if($viewAssembly + && $this->containsPartialExtends($model) + && $this->containsHeaderOrFooterView($viewAssembly)) { + throw new HeaderFooterPartialConflictException( + "Header/footer view files cannot be combined with partial views." + ); + } + $componentList = new LogicAssemblyComponentList(); try { @@ -81,4 +91,54 @@ function processPartialContent( return $componentList; } + + private function containsHeaderOrFooterView(Assembly $viewAssembly):bool { + foreach($viewAssembly as $viewFile) { + $fileName = pathinfo($viewFile, PATHINFO_FILENAME); + if($fileName === "_header" || $fileName === "_footer") { + return true; + } + } + + return false; + } + + private function containsPartialExtends(HTMLDocument $model):bool { + return $this->containsPartialExtendsInNode($model->documentElement); + } + + /** @return ?array|string> */ + private function parseCommentIni(string $data):?array { + set_error_handler( + static fn() => true + ); + + try { + $parsed = parse_ini_string($data, true); + } + finally { + restore_error_handler(); + } + + return is_array($parsed) + ? $parsed + : null; + } + + private function containsPartialExtendsInNode(\DOMNode $node):bool { + if($node->nodeType === XML_COMMENT_NODE) { + $parsed = $this->parseCommentIni(trim($node->textContent)); + if(isset($parsed["extends"])) { + return true; + } + } + + foreach($node->childNodes as $childNode) { + if($this->containsPartialExtendsInNode($childNode)) { + return true; + } + } + + return false; + } } diff --git a/src/Logic/ViewModelProcessor.php b/src/Logic/ViewModelProcessor.php index 230e0947..28a85d01 100644 --- a/src/Logic/ViewModelProcessor.php +++ b/src/Logic/ViewModelProcessor.php @@ -1,8 +1,8 @@ cwd = getcwd(); + $this->tmpDir = sys_get_temp_dir() . "/phpgt-webengine-test--DefaultRouter-" . uniqid(); + mkdir($this->tmpDir . "/page/admin", recursive: true); + } + + protected function tearDown():void { + chdir($this->cwd); + $this->removeDirectory($this->tmpDir); + parent::tearDown(); + } + + public function testRoute_pageRequest_includesHeadersAndFootersInNestedOrder():void { + file_put_contents($this->tmpDir . "/page/_header.html", "
site
"); + file_put_contents($this->tmpDir . "/page/admin/_header.html", ""); + file_put_contents($this->tmpDir . "/page/admin/users.html", "
users
"); + file_put_contents($this->tmpDir . "/page/admin/_footer.html", ""); + file_put_contents($this->tmpDir . "/page/_footer.html", ""); + + chdir($this->tmpDir); + + $request = self::createMock(Request::class); + $request->method("getMethod")->willReturn("GET"); + $request->method("getHeaderLine") + ->with("accept") + ->willReturn("text/html"); + $request->method("getUri")->willReturn(new Uri("https://example.test/admin/users")); + + $sut = new DefaultRouter(new RouterConfig(307, "text/html")); + $container = new Container(); + $container->set($request); + $sut->setContainer($container); + $sut->route($request); + + self::assertSame( + [ + "page/_header.html", + "page/admin/_header.html", + "page/admin/users.html", + "page/admin/_footer.html", + "page/_footer.html", + ], + iterator_to_array($sut->getViewAssembly()), + ); + } + + public function testRoute_pageRequest_withHeadersFootersAndPartials_throwsLogicException():void { + class_exists(HTMLDocument::class); + class_exists(ComponentExpander::class); + class_exists(PartialContent::class); + class_exists(PartialContentDirectoryNotFoundException::class); + class_exists(PartialExpander::class); + + file_put_contents($this->tmpDir . "/page/_header.html", "
site
"); + file_put_contents($this->tmpDir . "/page/admin/_header.html", ""); + file_put_contents($this->tmpDir . "/page/admin/users.html", "
users
"); + file_put_contents($this->tmpDir . "/page/admin/_footer.html", ""); + file_put_contents($this->tmpDir . "/page/_footer.html", ""); + mkdir($this->tmpDir . "/page/_partial", recursive: true); + file_put_contents( + $this->tmpDir . "/page/_partial/layout.html", + "
", + ); + + chdir($this->tmpDir); + + $request = self::createMock(Request::class); + $request->method("getMethod")->willReturn("GET"); + $request->method("getHeaderLine") + ->with("accept") + ->willReturn("text/html"); + $request->method("getUri")->willReturn(new Uri("https://example.test/admin/users")); + + $sut = new DefaultRouter(new RouterConfig(307, "text/html")); + $container = new Container(); + $container->set($request); + $sut->setContainer($container); + $sut->route($request); + + $view = new HTMLView(new Stream()); + foreach($sut->getViewAssembly() as $viewFile) { + $view->addViewFile($viewFile); + } + $viewModel = $view->createViewModel(); + + $processor = new HTMLDocumentProcessor("components", "page/_partial"); + $this->expectException(HeaderFooterPartialConflictException::class); + $this->expectExceptionMessage( + "Header/footer view files cannot be combined with partial views." + ); + $processor->processPartialContent($viewModel, $sut->getViewAssembly()); + } + + private function removeDirectory(string $dir):void { + if(!is_dir($dir)) { + return; + } + + foreach(scandir($dir) ?: [] as $file) { + if($file === "." || $file === "..") { + continue; + } + + $path = $dir . DIRECTORY_SEPARATOR . $file; + if(is_dir($path)) { + $this->removeDirectory($path); + continue; + } + + unlink($path); + } + + rmdir($dir); + } +}