Skip to content

Commit a9e491d

Browse files
authored
Ensure mutually exclusive usage for header/footer and partials (#670)
* test: integration test for _header _footer inclusion for #288 * test: isolate functionality for #288 * feature: throw exception if header/footer and partials used at same time closes #288 * tweak: do not symlink entire data by default
1 parent 38ae4e7 commit a9e491d

File tree

6 files changed

+210
-11
lines changed

6 files changed

+210
-11
lines changed

build.default.json

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,5 @@
4747
"command": "vendor/bin/sync",
4848
"arguments": ["asset/", "www/asset", "--delete", "--symlink"]
4949
}
50-
},
51-
52-
"data/**/*": {
53-
"require": {
54-
"vendor/bin/sync": "*"
55-
},
56-
"execute": {
57-
"command": "vendor/bin/sync",
58-
"arguments": ["data/", "www/data", "--delete", "--symlink"]
59-
}
6050
}
6151
}

src/Dispatch/Dispatcher.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,7 @@ public function processResponse(
403403

404404
$componentList = $this->viewModelProcessor?->processPartialContent(
405405
$this->viewModel,
406+
$this->viewAssembly,
406407
);
407408

408409
// TODO: CSRF handling - needs to be done on any POST request.

src/Logic/HTMLDocumentProcessor.php

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use GT\DomTemplate\PartialExpander;
99
use GT\Routing\Assembly;
1010
use GT\Routing\Path\DynamicPath;
11+
use GT\WebEngine\View\HeaderFooterPartialConflictException;
1112

1213
class HTMLDocumentProcessor extends ViewModelProcessor {
1314
function processDynamicPath(
@@ -30,7 +31,16 @@ function processDynamicPath(
3031

3132
function processPartialContent(
3233
HTMLDocument $model,
34+
?Assembly $viewAssembly = null,
3335
):LogicAssemblyComponentList {
36+
if($viewAssembly
37+
&& $this->containsPartialExtends($model)
38+
&& $this->containsHeaderOrFooterView($viewAssembly)) {
39+
throw new HeaderFooterPartialConflictException(
40+
"Header/footer view files cannot be combined with partial views."
41+
);
42+
}
43+
3444
$componentList = new LogicAssemblyComponentList();
3545

3646
try {
@@ -81,4 +91,54 @@ function processPartialContent(
8191

8292
return $componentList;
8393
}
94+
95+
private function containsHeaderOrFooterView(Assembly $viewAssembly):bool {
96+
foreach($viewAssembly as $viewFile) {
97+
$fileName = pathinfo($viewFile, PATHINFO_FILENAME);
98+
if($fileName === "_header" || $fileName === "_footer") {
99+
return true;
100+
}
101+
}
102+
103+
return false;
104+
}
105+
106+
private function containsPartialExtends(HTMLDocument $model):bool {
107+
return $this->containsPartialExtendsInNode($model->documentElement);
108+
}
109+
110+
/** @return ?array<string, array<string, string>|string> */
111+
private function parseCommentIni(string $data):?array {
112+
set_error_handler(
113+
static fn() => true
114+
);
115+
116+
try {
117+
$parsed = parse_ini_string($data, true);
118+
}
119+
finally {
120+
restore_error_handler();
121+
}
122+
123+
return is_array($parsed)
124+
? $parsed
125+
: null;
126+
}
127+
128+
private function containsPartialExtendsInNode(\DOMNode $node):bool {
129+
if($node->nodeType === XML_COMMENT_NODE) {
130+
$parsed = $this->parseCommentIni(trim($node->textContent));
131+
if(isset($parsed["extends"])) {
132+
return true;
133+
}
134+
}
135+
136+
foreach($node->childNodes as $childNode) {
137+
if($this->containsPartialExtendsInNode($childNode)) {
138+
return true;
139+
}
140+
}
141+
142+
return false;
143+
}
84144
}

src/Logic/ViewModelProcessor.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<?php
22
namespace GT\WebEngine\Logic;
33

4-
use Generator;
54
use GT\Dom\HTMLDocument;
5+
use GT\Routing\Assembly;
66
use GT\Routing\Path\DynamicPath;
77

88
abstract class ViewModelProcessor {
@@ -18,5 +18,6 @@ abstract function processDynamicPath(
1818

1919
abstract function processPartialContent(
2020
HTMLDocument $model,
21+
?Assembly $viewAssembly = null,
2122
):LogicAssemblyComponentList;
2223
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<?php
2+
namespace GT\WebEngine\View;
3+
4+
use GT\WebEngine\WebEngineException;
5+
6+
class HeaderFooterPartialConflictException extends WebEngineException {}

test/phpunit/DefaultRouterTest.php

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
<?php
2+
namespace GT\WebEngine\Test;
3+
4+
use GT\Dom\HTMLDocument;
5+
use GT\DomTemplate\ComponentExpander;
6+
use GT\DomTemplate\PartialContent;
7+
use GT\DomTemplate\PartialContentDirectoryNotFoundException;
8+
use GT\DomTemplate\PartialExpander;
9+
use Gt\Http\Request;
10+
use Gt\Http\Stream;
11+
use Gt\Http\Uri;
12+
use GT\WebEngine\Logic\HTMLDocumentProcessor;
13+
use GT\Routing\RouterConfig;
14+
use GT\WebEngine\DefaultRouter;
15+
use Gt\ServiceContainer\Container;
16+
use GT\WebEngine\View\HeaderFooterPartialConflictException;
17+
use GT\WebEngine\View\HTMLView;
18+
use PHPUnit\Framework\TestCase;
19+
20+
require_once dirname(__DIR__, 2) . "/router.default.php";
21+
22+
class DefaultRouterTest extends TestCase {
23+
private string $tmpDir;
24+
private string $cwd;
25+
26+
protected function setUp():void {
27+
parent::setUp();
28+
$this->cwd = getcwd();
29+
$this->tmpDir = sys_get_temp_dir() . "/phpgt-webengine-test--DefaultRouter-" . uniqid();
30+
mkdir($this->tmpDir . "/page/admin", recursive: true);
31+
}
32+
33+
protected function tearDown():void {
34+
chdir($this->cwd);
35+
$this->removeDirectory($this->tmpDir);
36+
parent::tearDown();
37+
}
38+
39+
public function testRoute_pageRequest_includesHeadersAndFootersInNestedOrder():void {
40+
file_put_contents($this->tmpDir . "/page/_header.html", "<html><body><header>site</header>");
41+
file_put_contents($this->tmpDir . "/page/admin/_header.html", "<nav>admin</nav>");
42+
file_put_contents($this->tmpDir . "/page/admin/users.html", "<main>users</main>");
43+
file_put_contents($this->tmpDir . "/page/admin/_footer.html", "<footer>admin</footer>");
44+
file_put_contents($this->tmpDir . "/page/_footer.html", "<footer>site</footer></body></html>");
45+
46+
chdir($this->tmpDir);
47+
48+
$request = self::createMock(Request::class);
49+
$request->method("getMethod")->willReturn("GET");
50+
$request->method("getHeaderLine")
51+
->with("accept")
52+
->willReturn("text/html");
53+
$request->method("getUri")->willReturn(new Uri("https://example.test/admin/users"));
54+
55+
$sut = new DefaultRouter(new RouterConfig(307, "text/html"));
56+
$container = new Container();
57+
$container->set($request);
58+
$sut->setContainer($container);
59+
$sut->route($request);
60+
61+
self::assertSame(
62+
[
63+
"page/_header.html",
64+
"page/admin/_header.html",
65+
"page/admin/users.html",
66+
"page/admin/_footer.html",
67+
"page/_footer.html",
68+
],
69+
iterator_to_array($sut->getViewAssembly()),
70+
);
71+
}
72+
73+
public function testRoute_pageRequest_withHeadersFootersAndPartials_throwsLogicException():void {
74+
class_exists(HTMLDocument::class);
75+
class_exists(ComponentExpander::class);
76+
class_exists(PartialContent::class);
77+
class_exists(PartialContentDirectoryNotFoundException::class);
78+
class_exists(PartialExpander::class);
79+
80+
file_put_contents($this->tmpDir . "/page/_header.html", "<html><body><header>site</header>");
81+
file_put_contents($this->tmpDir . "/page/admin/_header.html", "<nav>admin</nav>");
82+
file_put_contents($this->tmpDir . "/page/admin/users.html", "<!-- extends=layout --><main>users</main>");
83+
file_put_contents($this->tmpDir . "/page/admin/_footer.html", "<footer>admin</footer>");
84+
file_put_contents($this->tmpDir . "/page/_footer.html", "<footer>site</footer></body></html>");
85+
mkdir($this->tmpDir . "/page/_partial", recursive: true);
86+
file_put_contents(
87+
$this->tmpDir . "/page/_partial/layout.html",
88+
"<!doctype html><html><body><section data-partial></section></body></html>",
89+
);
90+
91+
chdir($this->tmpDir);
92+
93+
$request = self::createMock(Request::class);
94+
$request->method("getMethod")->willReturn("GET");
95+
$request->method("getHeaderLine")
96+
->with("accept")
97+
->willReturn("text/html");
98+
$request->method("getUri")->willReturn(new Uri("https://example.test/admin/users"));
99+
100+
$sut = new DefaultRouter(new RouterConfig(307, "text/html"));
101+
$container = new Container();
102+
$container->set($request);
103+
$sut->setContainer($container);
104+
$sut->route($request);
105+
106+
$view = new HTMLView(new Stream());
107+
foreach($sut->getViewAssembly() as $viewFile) {
108+
$view->addViewFile($viewFile);
109+
}
110+
$viewModel = $view->createViewModel();
111+
112+
$processor = new HTMLDocumentProcessor("components", "page/_partial");
113+
$this->expectException(HeaderFooterPartialConflictException::class);
114+
$this->expectExceptionMessage(
115+
"Header/footer view files cannot be combined with partial views."
116+
);
117+
$processor->processPartialContent($viewModel, $sut->getViewAssembly());
118+
}
119+
120+
private function removeDirectory(string $dir):void {
121+
if(!is_dir($dir)) {
122+
return;
123+
}
124+
125+
foreach(scandir($dir) ?: [] as $file) {
126+
if($file === "." || $file === "..") {
127+
continue;
128+
}
129+
130+
$path = $dir . DIRECTORY_SEPARATOR . $file;
131+
if(is_dir($path)) {
132+
$this->removeDirectory($path);
133+
continue;
134+
}
135+
136+
unlink($path);
137+
}
138+
139+
rmdir($dir);
140+
}
141+
}

0 commit comments

Comments
 (0)