From bc5991fdaa2065582f7e68d09ea7b78fd85b1143 Mon Sep 17 00:00:00 2001 From: Joby Elliott Date: Mon, 12 Dec 2022 18:34:42 -0700 Subject: [PATCH] rebuilt things using generic grouped containers, needs tests --- src/ContainerInterface.php | 4 + src/Containers/ContainerGroup.php | 144 ++++++++++++++++++ src/Containers/DocumentTags/HeadTag.php | 24 ++- src/Containers/DocumentTags/HtmlTag.php | 32 ++-- src/Containers/GenericHtmlDocument.php | 42 ++--- src/Containers/GroupedContainer.php | 27 ++++ src/Tags/AbstractGroupedTag.php | 27 +++- src/Traits/ContainerTrait.php | 10 ++ src/Traits/GroupedContainerTrait.php | 105 +++++++++++++ tests/Containers/ContainerGroupTest.php | 22 +++ tests/Containers/DocumentTags/HeadTagTest.php | 2 +- tests/Containers/FragmentTest.php | 1 - tests/Containers/GenericHtmlDocumentTest.php | 6 +- 13 files changed, 396 insertions(+), 50 deletions(-) create mode 100644 src/Containers/ContainerGroup.php create mode 100644 src/Containers/GroupedContainer.php create mode 100644 src/Traits/GroupedContainerTrait.php create mode 100644 tests/Containers/ContainerGroupTest.php diff --git a/src/ContainerInterface.php b/src/ContainerInterface.php index 218139b..fc75c5e 100644 --- a/src/ContainerInterface.php +++ b/src/ContainerInterface.php @@ -9,6 +9,10 @@ interface ContainerInterface extends Stringable /** @return array */ public function children(): array; + public function contains( + NodeInterface|Stringable|string $child + ): bool; + public function addChild( NodeInterface|Stringable|string $child, bool $prepend = false, diff --git a/src/Containers/ContainerGroup.php b/src/Containers/ContainerGroup.php new file mode 100644 index 0000000..bcb6d3e --- /dev/null +++ b/src/Containers/ContainerGroup.php @@ -0,0 +1,144 @@ + children() + */ +class ContainerGroup implements ContainerInterface, NodeInterface +{ + use NodeTrait; + use ContainerTrait { + ContainerTrait::contains as doContains; + ContainerTrait::addChild as doAddChild; + ContainerTrait::addChildBefore as doAddChildBefore; + ContainerTrait::addChildAfter as doAddChildAfter; + } + + /** @var callable */ + protected $validator; + /** @var int */ + protected $limit = 0; + + /** + * @return ContainerGroup + */ + public static function catchAll(): ContainerGroup + { + return new ContainerGroup( + function () { + return true; + }, + 0 + ); + } + + /** + * @template C of T + * @param class-string $class + * @param int $limit + * @return ContainerGroup + */ + public static function ofClass(string $class, int $limit = 0): ContainerGroup + { + return new ContainerGroup( // @phpstan-ignore-line + function (NodeInterface $child) use ($class): bool { + return $child instanceof $class; + }, + $limit + ); + } + + /** + * @param string $tag + * @param int $limit + * @return ContainerGroup + */ + public static function ofTag(string $tag, int $limit = 0): ContainerGroup + { + return new ContainerGroup( // @phpstan-ignore-line + function (NodeInterface $child) use ($tag): bool { + if ($child instanceof TagInterface) { + return $child->tag() == $tag; + } else { + return false; + } + }, + $limit + ); + } + + public function addChild(NodeInterface|Stringable|string $child, bool $prepend = false, bool $skip_sanitize = false): static + { + if ($this->willAccept($child)) { + $this->makeRoomForChild($prepend); + $this->doAddChild($child, $prepend, $skip_sanitize); + } + return $this; + } + + public function addChildAfter(NodeInterface|Stringable|string $new_child, NodeInterface|Stringable|string $after_child, bool $skip_sanitize = false): static + { + if ($this->willAccept($new_child)) { + $this->makeRoomForChild(false); + $this->doAddChildAfter($new_child, $after_child, $skip_sanitize); + } + return $this; + } + + public function addChildBefore(NodeInterface|Stringable|string $new_child, NodeInterface|Stringable|string $before_child, bool $skip_sanitize = false): static + { + if ($this->willAccept($new_child)) { + $this->makeRoomForChild(true); + $this->doAddChildBefore($new_child, $before_child, $skip_sanitize); + } + return $this; + } + + protected function makeRoomForChild(bool $remove_from_end): void + { + if ($this->limit > 0) { + while (count($this->children) > $this->limit) { + if ($remove_from_end) { + $child = array_pop($this->children); + } else { + $child = array_shift($this->children); + } + $child->setParent(null); + } + } + } + + public function willAccept(NodeInterface|Stringable|string $child, bool $check_limit = true): bool + { + if ($check_limit && $this->limit > 0) { + if (count($this->children()) >= $this->limit) { + return false; + } + } + if ($child instanceof NodeInterface) { + $child = $child->detachCopy(); + } + $child = $this->prepareChildToAdd($child, false); + return !!call_user_func($this->validator, $child); + } + + public function __construct(callable $validator, int $limit = 0) + { + $this->validator = $validator; + $this->limit = $limit; + } + + public function __toString(): string + { + return implode(PHP_EOL, $this->children()); + } +} diff --git a/src/Containers/DocumentTags/HeadTag.php b/src/Containers/DocumentTags/HeadTag.php index 2bf12f3..32631c1 100644 --- a/src/Containers/DocumentTags/HeadTag.php +++ b/src/Containers/DocumentTags/HeadTag.php @@ -2,24 +2,36 @@ namespace ByJoby\HTML\Containers\DocumentTags; -use ByJoby\HTML\Tags\AbstractContainerTag; +use ByJoby\HTML\Containers\ContainerGroup; +use ByJoby\HTML\Tags\AbstractGroupedTag; +use ByJoby\HTML\Traits\GroupedContainerTrait; -class HeadTag extends AbstractContainerTag implements HeadTagInterface +class HeadTag extends AbstractGroupedTag implements HeadTagInterface { const TAG = 'head'; - /** @var TitleTagInterface */ + use GroupedContainerTrait; + + /** @var ContainerGroup */ protected $title; public function __construct() { parent::__construct(); - $this->title = (new TitleTag); - $this->addChild($this->title); + $this->title = ContainerGroup::ofClass(TitleTagInterface::class, 1); + $this->addGroup($this->title); + $this->addChild(new TitleTag); + $this->addGroup(ContainerGroup::ofTag('meta')); + $this->addGroup(ContainerGroup::ofTag('base', 1)); + $this->addGroup(ContainerGroup::ofTag('style')); + $this->addGroup(ContainerGroup::ofTag('script')); + $this->addGroup(ContainerGroup::ofTag('noscript')); + $this->addGroup(ContainerGroup::ofTag('template', 1)); + $this->addGroup(ContainerGroup::catchAll()); } public function title(): TitleTagInterface { - return $this->title; + return $this->title->children()[0]; } } diff --git a/src/Containers/DocumentTags/HtmlTag.php b/src/Containers/DocumentTags/HtmlTag.php index 712e70a..9f8cfea 100644 --- a/src/Containers/DocumentTags/HtmlTag.php +++ b/src/Containers/DocumentTags/HtmlTag.php @@ -2,39 +2,39 @@ namespace ByJoby\HTML\Containers\DocumentTags; -use ByJoby\HTML\Tags\AbstractContainerTag; +use ByJoby\HTML\Containers\ContainerGroup; +use ByJoby\HTML\Tags\AbstractGroupedTag; +use ByJoby\HTML\Traits\GroupedContainerTrait; -class HtmlTag extends AbstractContainerTag implements HtmlTagInterface +class HtmlTag extends AbstractGroupedTag implements HtmlTagInterface { const TAG = 'html'; - /** @var HeadTagInterface */ + use GroupedContainerTrait; + + /** @var ContainerGroup */ protected $head; - /** @var BodyTagInterface */ + /** @var ContainerGroup */ protected $body; public function __construct() { parent::__construct(); - $this->head = (new HeadTag)->setParent($this); - $this->body = (new BodyTag)->setParent($this); - } - - public function children(): array - { - return [ - $this->head(), - $this->body() - ]; + $this->head = ContainerGroup::ofClass(HeadTagInterface::class); + $this->body = ContainerGroup::ofClass(BodyTagInterface::class, 1); + $this->addGroup($this->head); + $this->addGroup($this->body); + $this->addChild(new HeadTag); + $this->addChild(new BodyTag); } public function head(): HeadTagInterface { - return $this->head; + return $this->head->children()[0]; } public function body(): BodyTagInterface { - return $this->body; + return $this->body->children()[0]; } } diff --git a/src/Containers/GenericHtmlDocument.php b/src/Containers/GenericHtmlDocument.php index 1eb33c0..5e40d28 100644 --- a/src/Containers/GenericHtmlDocument.php +++ b/src/Containers/GenericHtmlDocument.php @@ -8,33 +8,35 @@ use ByJoby\HTML\Containers\DocumentTags\DoctypeInterface; use ByJoby\HTML\Containers\DocumentTags\HeadTagInterface; use ByJoby\HTML\Containers\DocumentTags\HtmlTag; use ByJoby\HTML\Containers\DocumentTags\HtmlTagInterface; -use ByJoby\HTML\Traits\ContainerTrait; +use ByJoby\HTML\Traits\GroupedContainerTrait; class GenericHtmlDocument implements HtmlDocumentInterface { - use ContainerTrait; + use GroupedContainerTrait; - /** @var DoctypeInterface */ + /** @var ContainerGroup */ protected $doctype; - /** @var HtmlTagInterface */ + /** @var ContainerGroup */ protected $html; public function __construct() { - $this->doctype = (new Doctype); - $this->html = (new HtmlTag); - $this->addChild($this->doctype); - $this->addChild($this->html); + $this->doctype = ContainerGroup::ofClass(DoctypeInterface::class, 1); + $this->html = ContainerGroup::ofClass(HtmlTagInterface::class, 1); + $this->addGroup($this->doctype); + $this->addGroup($this->html); + $this->addChild(new Doctype); + $this->addChild(new HtmlTag); } public function doctype(): DoctypeInterface { - return $this->doctype; + return $this->doctype->children()[0]; } public function html(): HtmlTagInterface { - return $this->html; + return $this->html->children()[0]; } public function head(): HeadTagInterface @@ -47,18 +49,16 @@ class GenericHtmlDocument implements HtmlDocumentInterface return $this->html()->body(); } - public function children(): array - { - return [ - $this->doctype(), - $this->html() - ]; - } - public function __toString(): string { - return $this->doctype() - . PHP_EOL - . $this->html(); + return implode( + PHP_EOL, + array_filter( + $this->groups(), + function (ContainerGroup $group) { + return !!$group->children(); + } + ) + ); } } diff --git a/src/Containers/GroupedContainer.php b/src/Containers/GroupedContainer.php new file mode 100644 index 0000000..123a5af --- /dev/null +++ b/src/Containers/GroupedContainer.php @@ -0,0 +1,27 @@ +groups(), + function (ContainerGroup $group) { + return !!$group->children(); + } + ) + ); + } +} diff --git a/src/Tags/AbstractGroupedTag.php b/src/Tags/AbstractGroupedTag.php index 96e238b..4188ca3 100644 --- a/src/Tags/AbstractGroupedTag.php +++ b/src/Tags/AbstractGroupedTag.php @@ -2,6 +2,31 @@ namespace ByJoby\HTML\Tags; -abstract class AbstractGroupedTag extends AbstractContainerTag +use ByJoby\HTML\Containers\ContainerGroup; +use ByJoby\HTML\Traits\GroupedContainerTrait; + +abstract class AbstractGroupedTag extends AbstractTag implements ContainerTagInterface { + use GroupedContainerTrait; + + public function __toString(): string + { + $openingTag = sprintf('<%s>', implode(' ', $this->openingTagStrings())); + $closingTag = sprintf('', $this->tag()); + $groups = array_filter( + $this->groups(), + function (ContainerGroup $group) { + return !!$group->children(); + } + ); + if (!$groups) return $openingTag . $closingTag; + else return implode( + PHP_EOL, + [ + $openingTag, + implode(PHP_EOL, $groups), + $closingTag + ] + ); + } } diff --git a/src/Traits/ContainerTrait.php b/src/Traits/ContainerTrait.php index a5a8129..17fa377 100644 --- a/src/Traits/ContainerTrait.php +++ b/src/Traits/ContainerTrait.php @@ -19,6 +19,16 @@ trait ContainerTrait return $this->children; } + public function contains( + NodeInterface|Stringable|string $child + ): bool { + if ($child instanceof NodeInterface) { + return $child->parent() === $this; + } else { + return $this->indexOfChild($child) !== null; + } + } + public function addChild( NodeInterface|Stringable|string $child, bool $prepend = false, diff --git a/src/Traits/GroupedContainerTrait.php b/src/Traits/GroupedContainerTrait.php new file mode 100644 index 0000000..dc17f6c --- /dev/null +++ b/src/Traits/GroupedContainerTrait.php @@ -0,0 +1,105 @@ +> + */ + public function groups(): array + { + return array_filter( + $this->children(), + function (NodeInterface $node) { + return $node instanceof ContainerGroup; + } + ); + } + + public function willAccept(NodeInterface|Stringable|string $child): bool + { + foreach ($this->groups() as $group) { + if ($group->willAccept($child)) return true; + } + return false; + } + + public function contains( + NodeInterface|Stringable|string $child + ): bool { + foreach ($this->groups() as $group) { + if ($group->contains($child)) { + return true; + } + } + return false; + } + + public function addChild( + NodeInterface|Stringable|string $child, + bool $prepend = false, + bool $skip_sanitize = false + ): static { + foreach ($this->groups() as $group) { + if ($group->willAccept($child)) { + $group->addChild($child, $prepend, $skip_sanitize); + break; + } + } + return $this; + } + + public function removeChild( + NodeInterface|Stringable|string $child + ): static { + foreach ($this->groups() as $group) { + $group->removeChild($child); + } + return $this; + } + + public function addChildBefore( + NodeInterface|Stringable|string $new_child, + NodeInterface|Stringable|string $before_child, + bool $skip_sanitize = false + ): static { + foreach ($this->groups() as $group) { + if ($group->willAccept($new_child) && $group->contains($before_child)) { + $group->addChildBefore($new_child, $before_child, $skip_sanitize); + break; + } + } + return $this; + } + + public function addChildAfter( + NodeInterface|Stringable|string $new_child, + NodeInterface|Stringable|string $after_child, + bool $skip_sanitize = false + ): static { + foreach ($this->groups() as $group) { + if ($group->willAccept($new_child) && $group->contains($after_child)) { + $group->addChildAfter($new_child, $after_child, $skip_sanitize); + break; + } + } + return $this; + } +} diff --git a/tests/Containers/ContainerGroupTest.php b/tests/Containers/ContainerGroupTest.php new file mode 100644 index 0000000..023131b --- /dev/null +++ b/tests/Containers/ContainerGroupTest.php @@ -0,0 +1,22 @@ +assertTrue($group->willAccept('Foo')); + } + + public function testCatchNone() + { + $group = new ContainerGroup(function () { + return false; + }); + $this->assertFalse($group->willAccept('Foo')); + } +} diff --git a/tests/Containers/DocumentTags/HeadTagTest.php b/tests/Containers/DocumentTags/HeadTagTest.php index 9a7e989..30c36c6 100644 --- a/tests/Containers/DocumentTags/HeadTagTest.php +++ b/tests/Containers/DocumentTags/HeadTagTest.php @@ -10,6 +10,6 @@ class HeadTagTest extends TestCase { $head = new HeadTag; $this->assertInstanceOf(TitleTagInterface::class, $head->title()); - $this->assertEquals($head, $head->title()->parent()); + $this->assertEquals($head, $head->title()->parentTag()); } } diff --git a/tests/Containers/FragmentTest.php b/tests/Containers/FragmentTest.php index 5f528f4..ce7fa35 100644 --- a/tests/Containers/FragmentTest.php +++ b/tests/Containers/FragmentTest.php @@ -3,7 +3,6 @@ namespace ByJoby\HTML\Containers; use ByJoby\HTML\Tags\AbstractContainerTag; -use ByJoby\HTML\Tags\AbstractTag; use PHPUnit\Framework\TestCase; class FragmentTest extends TestCase diff --git a/tests/Containers/GenericHtmlDocumentTest.php b/tests/Containers/GenericHtmlDocumentTest.php index 9c42210..39f08ab 100644 --- a/tests/Containers/GenericHtmlDocumentTest.php +++ b/tests/Containers/GenericHtmlDocumentTest.php @@ -19,15 +19,13 @@ class GenericHtmlDocumentTest extends TestCase $this->assertInstanceOf(BodyTagInterface::class, $document->body()); $this->assertInstanceOf(HeadTagInterface::class, $document->head()); // body and head are being passed properly - $this->assertTrue($document->body() === $document->html()->body()); - $this->assertTrue($document->head() === $document->html()->head()); + $this->assertEquals($document->body(), $document->html()->body()); + $this->assertEquals($document->head(), $document->html()->head()); // everything has the correct document $this->assertEquals($document, $document->doctype()->parentDocument()); $this->assertEquals($document, $document->html()->parentDocument()); $this->assertEquals($document, $document->body()->parentDocument()); $this->assertEquals($document, $document->head()->parentDocument()); - // children are doctype and html - $this->assertEquals([$document->doctype(), $document->html()], $document->children()); // string version of an empty document $this->assertEquals( implode(