diff --git a/src/ContainerInterface.php b/src/ContainerInterface.php index c64493b..218139b 100644 --- a/src/ContainerInterface.php +++ b/src/ContainerInterface.php @@ -3,10 +3,31 @@ namespace ByJoby\HTML; use Stringable; -use Traversable; interface ContainerInterface extends Stringable { /** @return array */ public function children(): array; + + public function addChild( + NodeInterface|Stringable|string $child, + bool $prepend = false, + bool $skip_sanitize = false + ): static; + + public function removeChild( + NodeInterface|Stringable|string $child + ): static; + + public function addChildBefore( + NodeInterface|Stringable|string $new_child, + NodeInterface|Stringable|string $before_child, + bool $skip_sanitize = false + ): static; + + public function addChildAfter( + NodeInterface|Stringable|string $new_child, + NodeInterface|Stringable|string $after_child, + bool $skip_sanitize = false + ): static; } diff --git a/src/ContainerMutableInterface.php b/src/ContainerMutableInterface.php deleted file mode 100644 index bfa8a27..0000000 --- a/src/ContainerMutableInterface.php +++ /dev/null @@ -1,30 +0,0 @@ -title; } - public function detach(): static - { - throw new Exception('Not allowed to detach TitleTag'); - } - public function __toString(): string { return '' . $this->title() . ''; diff --git a/src/Containers/Fragment.php b/src/Containers/Fragment.php index 4f5214b..66c14c6 100644 --- a/src/Containers/Fragment.php +++ b/src/Containers/Fragment.php @@ -3,7 +3,6 @@ namespace ByJoby\HTML\Containers; use ByJoby\HTML\NodeInterface; -use ByJoby\HTML\Traits\ContainerMutableTrait; use ByJoby\HTML\Traits\ContainerTrait; use Stringable; use Traversable; @@ -11,7 +10,6 @@ use Traversable; class Fragment implements FragmentInterface { use ContainerTrait; - use ContainerMutableTrait; /** * @param null|array|Traversable|null $children diff --git a/src/Containers/FragmentInterface.php b/src/Containers/FragmentInterface.php index 7215815..39288bd 100644 --- a/src/Containers/FragmentInterface.php +++ b/src/Containers/FragmentInterface.php @@ -3,8 +3,7 @@ namespace ByJoby\HTML\Containers; use ByJoby\HTML\ContainerInterface; -use ByJoby\HTML\ContainerMutableInterface; -interface FragmentInterface extends DocumentInterface, ContainerMutableInterface +interface FragmentInterface extends DocumentInterface, ContainerInterface { } diff --git a/src/Containers/GenericHtmlDocument.php b/src/Containers/GenericHtmlDocument.php index ccfe18b..1eb33c0 100644 --- a/src/Containers/GenericHtmlDocument.php +++ b/src/Containers/GenericHtmlDocument.php @@ -2,17 +2,18 @@ namespace ByJoby\HTML\Containers; -use ByJoby\HTML\Containers\DocumentTags\BodyTag; use ByJoby\HTML\Containers\DocumentTags\BodyTagInterface; use ByJoby\HTML\Containers\DocumentTags\Doctype; use ByJoby\HTML\Containers\DocumentTags\DoctypeInterface; -use ByJoby\HTML\Containers\DocumentTags\HeadTag; use ByJoby\HTML\Containers\DocumentTags\HeadTagInterface; use ByJoby\HTML\Containers\DocumentTags\HtmlTag; use ByJoby\HTML\Containers\DocumentTags\HtmlTagInterface; +use ByJoby\HTML\Traits\ContainerTrait; class GenericHtmlDocument implements HtmlDocumentInterface { + use ContainerTrait; + /** @var DoctypeInterface */ protected $doctype; /** @var HtmlTagInterface */ @@ -20,8 +21,10 @@ class GenericHtmlDocument implements HtmlDocumentInterface public function __construct() { - $this->doctype = (new Doctype)->setDocument($this); - $this->html = (new HtmlTag)->setDocument($this); + $this->doctype = (new Doctype); + $this->html = (new HtmlTag); + $this->addChild($this->doctype); + $this->addChild($this->html); } public function doctype(): DoctypeInterface diff --git a/src/Helpers/Attributes.php b/src/Helpers/Attributes.php index 07d76bb..d014900 100644 --- a/src/Helpers/Attributes.php +++ b/src/Helpers/Attributes.php @@ -12,12 +12,12 @@ use Traversable; /** * Holds and validates a set of HTML attribute name/value pairs for use in tags. * - * @implements ArrayAccess - * @implements IteratorAggregate + * @implements ArrayAccess + * @implements IteratorAggregate */ class Attributes implements IteratorAggregate, ArrayAccess { - /** @var array */ + /** @var array */ protected $array = []; /** @var bool */ protected $sorted = true; @@ -25,7 +25,7 @@ class Attributes implements IteratorAggregate, ArrayAccess protected $disallowed = []; /** - * @param null|array $array + * @param null|array $array * @param array $disallowed * @return void */ @@ -59,6 +59,13 @@ class Attributes implements IteratorAggregate, ArrayAccess $this->array[$offset] = $value; } + public function string(string $offset): null|string + { + $value = $this->offsetGet($offset); + if (is_string($value)) return $value; + else return null; + } + function offsetUnset(mixed $offset): void { $offset = static::sanitizeOffset($offset); @@ -66,7 +73,7 @@ class Attributes implements IteratorAggregate, ArrayAccess } /** - * @return array + * @return array */ function getArray(): array { diff --git a/src/Html5/Tags/BaseTag.php b/src/Html5/Tags/BaseTag.php index 147e1cb..da499d1 100644 --- a/src/Html5/Tags/BaseTag.php +++ b/src/Html5/Tags/BaseTag.php @@ -11,12 +11,13 @@ class BaseTag extends AbstractTag implements MetadataContent public function href(): null|string { - return $this->attributes()['href']; + return $this->attributes()->string('href'); } public function setHref(null|string $href): static { - $this->attributes()['href'] = $href; + if (!$href) $this->attributes()['href'] = false; + else $this->attributes()['href'] = $href; return $this; } @@ -28,12 +29,13 @@ class BaseTag extends AbstractTag implements MetadataContent public function target(): null|string { - return $this->attributes()['target']; + return $this->attributes()->string('target'); } public function setTarget(null|string $target): static { - $this->attributes()['target'] = $target; + if (!$target) $this->attributes()['target'] = false; + else $this->attributes()['target'] = $target; return $this; } diff --git a/src/Html5/Tags/LinkTag.php b/src/Html5/Tags/LinkTag.php index 608a8e4..b8cff37 100644 --- a/src/Html5/Tags/LinkTag.php +++ b/src/Html5/Tags/LinkTag.php @@ -11,12 +11,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function rel(): null|string { - return $this->attributes()['rel']; + return $this->attributes()->string('rel'); } public function setRel(null|string $rel): static { - $this->attributes()['rel'] = $rel; + if (!$rel) $this->attributes()['rel'] = false; + else $this->attributes()['rel'] = $rel; return $this; } @@ -28,12 +29,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function as(): null|string { - return $this->attributes()['as']; + return $this->attributes()->string('as'); } public function setAs(null|string $as): static { - $this->attributes()['as'] = $as; + if (!$as) $this->attributes()['as'] = false; + else $this->attributes()['as'] = $as; return $this; } @@ -45,12 +47,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function crossorigin(): null|string { - return $this->attributes()['crossorigin']; + return $this->attributes()->string('crossorigin'); } public function setCrossorigin(null|string $crossorigin): static { - $this->attributes()['crossorigin'] = $crossorigin; + if (!$crossorigin) $this->attributes()['crossorigin'] = false; + else $this->attributes()['crossorigin'] = $crossorigin; return $this; } @@ -62,12 +65,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function href(): null|string { - return $this->attributes()['href']; + return $this->attributes()->string('href'); } public function setHref(null|string $href): static { - $this->attributes()['href'] = $href; + if (!$href) $this->attributes()['href'] = false; + else $this->attributes()['href'] = $href; return $this; } @@ -79,12 +83,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function hreflang(): null|string { - return $this->attributes()['hreflang']; + return $this->attributes()->string('hreflang'); } public function setHreflang(null|string $hreflang): static { - $this->attributes()['hreflang'] = $hreflang; + if (!$hreflang) $this->attributes()['hreflang'] = false; + else $this->attributes()['hreflang'] = $hreflang; return $this; } @@ -96,12 +101,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function imagesizes(): null|string { - return $this->attributes()['imagesizes']; + return $this->attributes()->string('imagesizes'); } public function setImagesizes(null|string $imagesizes): static { - $this->attributes()['imagesizes'] = $imagesizes; + if (!$imagesizes) $this->attributes()['imagesizes'] = false; + else $this->attributes()['imagesizes'] = $imagesizes; return $this; } @@ -113,12 +119,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function imagesrcset(): null|string { - return $this->attributes()['imagesrcset']; + return $this->attributes()->string('imagesrcset'); } public function setImagesrcset(null|string $imagesrcset): static { - $this->attributes()['imagesrcset'] = $imagesrcset; + if (!$imagesrcset) $this->attributes()['imagesrcset'] = false; + else $this->attributes()['imagesrcset'] = $imagesrcset; return $this; } @@ -130,12 +137,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function integrity(): null|string { - return $this->attributes()['integrity']; + return $this->attributes()->string('integrity'); } public function setIntegrity(null|string $integrity): static { - $this->attributes()['integrity'] = $integrity; + if (!$integrity) $this->attributes()['integrity'] = false; + else $this->attributes()['integrity'] = $integrity; return $this; } @@ -147,12 +155,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function media(): null|string { - return $this->attributes()['media']; + return $this->attributes()->string('media'); } public function setMedia(null|string $media): static { - $this->attributes()['media'] = $media; + if (!$media) $this->attributes()['media'] = false; + else $this->attributes()['media'] = $media; return $this; } @@ -164,12 +173,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function referrerpolicy(): null|string { - return $this->attributes()['referrerpolicy']; + return $this->attributes()->string('referrerpolicy'); } public function setReferrerpolicy(null|string $referrerpolicy): static { - $this->attributes()['referrerpolicy'] = $referrerpolicy; + if (!$referrerpolicy) $this->attributes()['referrerpolicy'] = false; + else $this->attributes()['referrerpolicy'] = $referrerpolicy; return $this; } @@ -181,12 +191,13 @@ class LinkTag extends AbstractTag implements MetadataContent public function type(): null|string { - return $this->attributes()['type']; + return $this->attributes()->string('type'); } public function setType(null|string $type): static { - $this->attributes()['type'] = $type; + if (!$type) $this->attributes()['type'] = false; + else $this->attributes()['type'] = $type; return $this; } diff --git a/src/Html5/Tags/MetaTag.php b/src/Html5/Tags/MetaTag.php index 8c3d347..2a9dc62 100644 --- a/src/Html5/Tags/MetaTag.php +++ b/src/Html5/Tags/MetaTag.php @@ -11,12 +11,13 @@ class MetaTag extends AbstractTag implements MetadataContent public function name(): null|string { - return $this->attributes()['name']; + return $this->attributes()->string('name'); } public function setName(null|string $name): static { - $this->attributes()['name'] = $name; + if (!$name) $this->attributes()['name'] = false; + else $this->attributes()['name'] = $name; return $this; } @@ -28,12 +29,13 @@ class MetaTag extends AbstractTag implements MetadataContent public function content(): null|string { - return $this->attributes()['content']; + return $this->attributes()->string('content'); } public function setContent(null|string $content): static { - $this->attributes()['content'] = $content; + if (!$content) $this->attributes()['content'] = false; + else $this->attributes()['content'] = $content; return $this; } @@ -45,12 +47,13 @@ class MetaTag extends AbstractTag implements MetadataContent public function httpEquiv(): null|string { - return $this->attributes()['http-equiv']; + return $this->attributes()->string('http-equiv'); } public function setHttpEquiv(null|string $http_equiv): static { - $this->attributes()['http-equiv'] = $http_equiv; + if (!$http_equiv) $this->attributes()['http-equiv'] = false; + else $this->attributes()['http-equiv'] = $http_equiv; return $this; } @@ -62,12 +65,13 @@ class MetaTag extends AbstractTag implements MetadataContent public function charset(): null|string { - return $this->attributes()['charset']; + return $this->attributes()->string('charset'); } public function setCharset(null|string $charset): static { - $this->attributes()['charset'] = $charset; + if (!$charset) $this->attributes()['charset'] = false; + else $this->attributes()['charset'] = $charset; return $this; } diff --git a/src/Html5/Tags/ScriptTag.php b/src/Html5/Tags/ScriptTag.php index a575389..b53c22f 100644 --- a/src/Html5/Tags/ScriptTag.php +++ b/src/Html5/Tags/ScriptTag.php @@ -13,36 +13,35 @@ class ScriptTag extends AbstractContentTag implements MetadataContent, PhrasingC public function setAsync(bool $async): static { - if ($async) $this->attributes()['async'] = null; - else unset($this->attributes()['async']); + $this->attributes()['async'] = $async; return $this; } public function async(): bool { - return isset($this->attributes()['async']); + return !!$this->attributes()['async']; } public function setDefer(bool $defer): static { - if ($defer) $this->attributes()['defer'] = null; - else unset($this->attributes()['defer']); + $this->attributes()['defer'] = $defer; return $this; } public function defer(): bool { - return isset($this->attributes()['defer']); + return !!$this->attributes()['defer']; } public function crossorigin(): null|string { - return $this->attributes()['crossorigin']; + return $this->attributes()->string('crossorigin'); } public function setCrossorigin(null|string $crossorigin): static { - $this->attributes()['crossorigin'] = $crossorigin; + if (!$crossorigin) $this->attributes()['crossorigin'] = false; + else $this->attributes()['crossorigin'] = $crossorigin; return $this; } @@ -54,12 +53,13 @@ class ScriptTag extends AbstractContentTag implements MetadataContent, PhrasingC public function integrity(): null|string { - return $this->attributes()['integrity']; + return $this->attributes()->string('integrity'); } public function setIntegrity(null|string $integrity): static { - $this->attributes()['integrity'] = $integrity; + if (!$integrity) $this->attributes()['integrity'] = false; + else $this->attributes()['integrity'] = $integrity; return $this; } @@ -69,31 +69,26 @@ class ScriptTag extends AbstractContentTag implements MetadataContent, PhrasingC return $this; } - public function nomodule(): null|string - { - return $this->attributes()['nomodule']; - } - - public function setNomodule(null|string $nomodule): static + public function setNomodule(bool $nomodule): static { $this->attributes()['nomodule'] = $nomodule; return $this; } - public function unsetNomodule(): static + public function nomodule(): bool { - unset($this->attributes()['nomodule']); - return $this; + return !!$this->attributes()['nomodule']; } public function nonce(): null|string { - return $this->attributes()['nonce']; + return $this->attributes()->string('nonce'); } public function setNonce(null|string $nonce): static { - $this->attributes()['nonce'] = $nonce; + if (!$nonce) $this->attributes()['nonce'] = false; + else $this->attributes()['nonce'] = $nonce; return $this; } @@ -105,12 +100,13 @@ class ScriptTag extends AbstractContentTag implements MetadataContent, PhrasingC public function referrerpolicy(): null|string { - return $this->attributes()['referrerpolicy']; + return $this->attributes()->string('referrerpolicy'); } public function setReferrerpolicy(null|string $referrerpolicy): static { - $this->attributes()['referrerpolicy'] = $referrerpolicy; + if (!$referrerpolicy) $this->attributes()['referrerpolicy'] = false; + else $this->attributes()['referrerpolicy'] = $referrerpolicy; return $this; } @@ -122,12 +118,13 @@ class ScriptTag extends AbstractContentTag implements MetadataContent, PhrasingC public function src(): null|string { - return $this->attributes()['src']; + return $this->attributes()->string('src'); } public function setSrc(null|string $src): static { - $this->attributes()['src'] = $src; + if (!$src) $this->attributes()['src'] = false; + else $this->attributes()['src'] = $src; return $this; } @@ -139,12 +136,13 @@ class ScriptTag extends AbstractContentTag implements MetadataContent, PhrasingC public function type(): null|string { - return $this->attributes()['type']; + return $this->attributes()->string('type'); } public function setType(null|string $type): static { - $this->attributes()['type'] = $type; + if (!$type) $this->attributes()['type'] = false; + else $this->attributes()['type'] = $type; return $this; } diff --git a/src/Html5/Tags/StyleTag.php b/src/Html5/Tags/StyleTag.php index 511bbf8..9d2656a 100644 --- a/src/Html5/Tags/StyleTag.php +++ b/src/Html5/Tags/StyleTag.php @@ -11,12 +11,13 @@ class StyleTag extends AbstractContentTag implements MetadataContent public function media(): null|string { - return $this->attributes()['media']; + return $this->attributes()->string('media'); } public function setMedia(null|string $media): static { - $this->attributes()['media'] = $media; + if (!$media) $this->attributes()['media'] = false; + else $this->attributes()['media'] = $media; return $this; } @@ -28,12 +29,13 @@ class StyleTag extends AbstractContentTag implements MetadataContent public function nonce(): null|string { - return $this->attributes()['nonce']; + return $this->attributes()->string('nonce'); } public function setNonce(null|string $nonce): static { - $this->attributes()['nonce'] = $nonce; + if (!$nonce) $this->attributes()['nonce'] = false; + else $this->attributes()['nonce'] = $nonce; return $this; } diff --git a/src/NodeInterface.php b/src/NodeInterface.php index 1e8f608..3ea28d2 100644 --- a/src/NodeInterface.php +++ b/src/NodeInterface.php @@ -3,21 +3,27 @@ namespace ByJoby\HTML; use ByJoby\HTML\Containers\DocumentInterface; +use ByJoby\HTML\Tags\TagInterface; use Stringable; interface NodeInterface extends Stringable { - public function parent(): null|NodeInterface; + public function parent(): null|ContainerInterface; public function setParent( - null|NodeInterface $parent + null|ContainerInterface $parent ): static; - public function document(): null|DocumentInterface; + public function parentTag(): null|TagInterface; - public function setDocument( - null|DocumentInterface $parent - ): static; + public function parentDocument(): null|DocumentInterface; - public function detach(): static; + /** + * @template T of NodeInterface + * @param class-string $class + * @return null|T + */ + public function parentOfType(string $class): mixed; + + public function detachCopy(): static; } diff --git a/src/Nodes/Comment.php b/src/Nodes/Comment.php index 1e8b074..741a335 100644 --- a/src/Nodes/Comment.php +++ b/src/Nodes/Comment.php @@ -13,14 +13,25 @@ class Comment implements CommentInterface { } + public function value(): string + { + return $this->value; + } + + public function setValue(string|Stringable $value): static + { + $this->value = $value; + return $this; + } + public function __toString(): string { return sprintf( '', str_replace( '--', // regular hyphens - '‑‑', // non-breaking hyphens - $this->value + '‑‑', // non-breaking hyphens, so they can't end the comment + $this->value() ) ); } diff --git a/src/Nodes/CommentInterface.php b/src/Nodes/CommentInterface.php index 0405dce..429919f 100644 --- a/src/Nodes/CommentInterface.php +++ b/src/Nodes/CommentInterface.php @@ -8,4 +8,6 @@ use Stringable; interface CommentInterface extends NodeInterface { public function __construct(Stringable|string $value); + public function value(): string; + public function setValue(string|Stringable $value): static; } diff --git a/src/Nodes/Text.php b/src/Nodes/Text.php index 0a1c87f..288aa3a 100644 --- a/src/Nodes/Text.php +++ b/src/Nodes/Text.php @@ -13,8 +13,19 @@ class Text implements TextInterface { } + public function value(): string + { + return $this->value; + } + + public function setValue(string|Stringable $value): static + { + $this->value = $value; + return $this; + } + public function __toString(): string { - return htmlentities(strip_tags($this->value)); + return htmlentities(strip_tags($this->value())); } } diff --git a/src/Nodes/TextInterface.php b/src/Nodes/TextInterface.php index 0abf881..64da86f 100644 --- a/src/Nodes/TextInterface.php +++ b/src/Nodes/TextInterface.php @@ -8,4 +8,6 @@ use Stringable; interface TextInterface extends NodeInterface { public function __construct(Stringable|string $value); + public function value(): string; + public function setValue(string|Stringable $value): static; } diff --git a/src/Nodes/UnsanitizedText.php b/src/Nodes/UnsanitizedText.php index 6def807..7203d97 100644 --- a/src/Nodes/UnsanitizedText.php +++ b/src/Nodes/UnsanitizedText.php @@ -13,8 +13,19 @@ class UnsanitizedText implements TextInterface { } - public function __toString(): string + public function value(): string { return $this->value; } + + public function setValue(string|Stringable $value): static + { + $this->value = $value; + return $this; + } + + public function __toString(): string + { + return $this->value(); + } } diff --git a/src/Tags/AbstractContainerTag.php b/src/Tags/AbstractContainerTag.php index baf60f1..7618f89 100644 --- a/src/Tags/AbstractContainerTag.php +++ b/src/Tags/AbstractContainerTag.php @@ -2,7 +2,6 @@ namespace ByJoby\HTML\Tags; -use ByJoby\HTML\Traits\ContainerMutableTrait; use ByJoby\HTML\Traits\ContainerTrait; use ByJoby\HTML\Traits\TagTrait; use ByJoby\HTML\Traits\NodeTrait; @@ -10,7 +9,7 @@ use ByJoby\HTML\Traits\NodeTrait; abstract class AbstractContainerTag extends AbstractTag implements ContainerTagInterface { use NodeTrait, TagTrait; - use ContainerTrait, ContainerMutableTrait; + use ContainerTrait; public function __toString(): string { diff --git a/src/Tags/AbstractGroupedTag.php b/src/Tags/AbstractGroupedTag.php new file mode 100644 index 0000000..96e238b --- /dev/null +++ b/src/Tags/AbstractGroupedTag.php @@ -0,0 +1,7 @@ +prepareChildToAdd($child, $skip_sanitize); - if ($prepend) array_unshift($this->children, $child); - else $this->children[] = $child; - return $this; - } - - public function removeChild( - NodeInterface|Stringable|string $child - ): static { - $this->children = array_filter( - $this->children, - function (NodeInterface $e) use ($child) { - if (is_object($child)) return $e !== $child; - else return $e != $child; - } - ); - return $this; - } - - public function addChildBefore( - NodeInterface|Stringable|string $new_child, - NodeInterface|Stringable|string $before_child, - bool $skip_sanitize = false - ): static { - $i = $this->indexOfChild($before_child); - if ($i === null) { - throw new Exception('Reference child not found in this container'); - } - $new_child = $this->prepareChildToAdd($new_child, $skip_sanitize); - array_splice($this->children, $i, 0, [$new_child]); - return $this; - } - - public function addChildAfter( - NodeInterface|Stringable|string $new_child, - NodeInterface|Stringable|string $after_child, - bool $skip_sanitize = false - ): static { - $i = $this->indexOfChild($after_child); - if ($i === null) { - throw new Exception('Reference child not found in this container'); - } - $new_child = $this->prepareChildToAdd($new_child, $skip_sanitize); - array_splice($this->children, $i + 1, 0, [$new_child]); - return $this; - } - - protected function prepareChildToAdd(NodeInterface|Stringable|string $child, bool $skip_sanitize): NodeInterface - { - if (!($child instanceof NodeInterface)) { - if ($skip_sanitize) $child = new UnsanitizedText($child); - else $child = new Text($child); - } - if ($this instanceof NodeInterface) { - if ($child->parent() || $child->document()) { - $child->detach(); - } - $child->setParent($this); - $child->setDocument($this->document()); - } - if ($this instanceof DocumentInterface) { - if ($child->parent() || $child->document()) { - $child->detach(); - } - $child->setDocument($this); - } - return $child; - } - - protected function indexOfChild(NodeInterface|Stringable|string $child): null|int - { - if ($child instanceof NodeInterface) { - foreach ($this->children() as $i => $v) { - if ($v === $child) return $i; - } - } else { - foreach ($this->children() as $i => $v) { - if ($v == $child) return $i; - } - } - return null; - } -} diff --git a/src/Traits/ContainerTrait.php b/src/Traits/ContainerTrait.php index ec81ba0..a5a8129 100644 --- a/src/Traits/ContainerTrait.php +++ b/src/Traits/ContainerTrait.php @@ -3,6 +3,10 @@ namespace ByJoby\HTML\Traits; use ByJoby\HTML\NodeInterface; +use ByJoby\HTML\Nodes\Text; +use ByJoby\HTML\Nodes\UnsanitizedText; +use Exception; +use Stringable; trait ContainerTrait { @@ -14,4 +18,89 @@ trait ContainerTrait { return $this->children; } + + public function addChild( + NodeInterface|Stringable|string $child, + bool $prepend = false, + bool $skip_sanitize = false + ): static { + $child = $this->prepareChildToAdd($child, $skip_sanitize); + if ($prepend) array_unshift($this->children, $child); + else $this->children[] = $child; + return $this; + } + + public function removeChild( + NodeInterface|Stringable|string $child + ): static { + $this->children = array_filter( + $this->children, + function (NodeInterface $e) use ($child) { + if (is_object($child)) $keep = $e !== $child; + else $keep = $e != $child; + if (!$keep) { + $e->setParent(null); + } + return $keep; + } + ); + return $this; + } + + public function addChildBefore( + NodeInterface|Stringable|string $new_child, + NodeInterface|Stringable|string $before_child, + bool $skip_sanitize = false + ): static { + $i = $this->indexOfChild($before_child); + if ($i === null) { + throw new Exception('Reference child not found in this container'); + } + $new_child = $this->prepareChildToAdd($new_child, $skip_sanitize); + array_splice($this->children, $i, 0, [$new_child]); + return $this; + } + + public function addChildAfter( + NodeInterface|Stringable|string $new_child, + NodeInterface|Stringable|string $after_child, + bool $skip_sanitize = false + ): static { + $i = $this->indexOfChild($after_child); + if ($i === null) { + throw new Exception('Reference child not found in this container'); + } + $new_child = $this->prepareChildToAdd($new_child, $skip_sanitize); + array_splice($this->children, $i + 1, 0, [$new_child]); + return $this; + } + + protected function prepareChildToAdd(NodeInterface|Stringable|string $child, bool $skip_sanitize): NodeInterface + { + // turn strings into nodes + if (!($child instanceof NodeInterface)) { + if ($skip_sanitize) $child = new UnsanitizedText($child); + else $child = new Text($child); + } + // remove from parent, move it here, and return + if ($parent = $child->parent()) { + $parent->removeChild($child); + } + $child->setParent($this); + return $child; + } + + protected function indexOfChild(NodeInterface|Stringable|string $child): null|int + { + if ($child instanceof NodeInterface) { + foreach ($this->children() as $i => $v) { + if ($v === $child) return $i; + } + } else { + foreach ($this->children() as $i => $v) { + if ($v == $child) return $i; + } + } + return null; + } } diff --git a/src/Traits/NodeTrait.php b/src/Traits/NodeTrait.php index f3d0ead..35fd34d 100644 --- a/src/Traits/NodeTrait.php +++ b/src/Traits/NodeTrait.php @@ -3,60 +3,47 @@ namespace ByJoby\HTML\Traits; use ByJoby\HTML\ContainerInterface; -use ByJoby\HTML\ContainerMutableInterface; use ByJoby\HTML\Containers\DocumentInterface; use ByJoby\HTML\NodeInterface; +use ByJoby\HTML\Tags\TagInterface; use DeepCopy\DeepCopy; -use Exception; trait NodeTrait { - /** @var null|NodeInterface */ + /** @var null|ContainerInterface */ protected $parent; - /** @var null|DocumentInterface */ - protected $document; abstract function __toString(); - public function parent(): null|NodeInterface + public function parent(): null|ContainerInterface { return $this->parent; } - public function setParent(null|NodeInterface $parent): static + public function setParent(null|ContainerInterface $parent): static { $this->parent = $parent; return $this; } - public function document(): null|DocumentInterface + public function parentTag(): null|TagInterface { - return $this->document; + return $this->parentOfType(TagInterface::class); // @phpstan-ignore-line } - public function setDocument(null|DocumentInterface $document): static + public function parentDocument(): null|DocumentInterface { - $this->document = $document; - if ($this instanceof ContainerInterface) { - foreach ($this->children() as $child) { - $child->setDocument($document); - } - } - return $this; + return $this->parentOfType(DocumentInterface::class); // @phpstan-ignore-line } - public function detach(): static + public function parentOfType(string $class): mixed { - $parent = $this->parent() ?? $this->document(); - if ($parent === null) { - return $this; - } elseif ($parent instanceof ContainerMutableInterface) { - $parent->removeChild($this); - $this->setParent(null); - $this->setDocument(null); - return $this; + if ($this->parent() instanceof $class) { + return $this->parent(); + } elseif ($this->parent() && $this->parent() instanceof NodeInterface) { + return $this->parent()->parentOfType($class); } else { - throw new Exception('Cannot detach() a Node from a parent that is not a ContainerMutableInterface, use detachCopy() instead'); + return null; } } @@ -65,7 +52,6 @@ trait NodeTrait static $copier; $copier = $copier ?? new DeepCopy(); return ($copier->copy($this)) - ->setParent(null) - ->setDocument(null); + ->setParent(null); } } diff --git a/src/Traits/TagTrait.php b/src/Traits/TagTrait.php index 5ba8e3e..a5371f7 100644 --- a/src/Traits/TagTrait.php +++ b/src/Traits/TagTrait.php @@ -64,7 +64,7 @@ trait TagTrait public function __toString(): string { - return sprintf('<%s/>', implode(' ', $this->openingTagStrings())); + return sprintf('<%s>', implode(' ', $this->openingTagStrings())); } /** @@ -81,8 +81,11 @@ trait TagTrait $strings[] = sprintf('style="%s"', $this->styles()); } foreach ($this->attributes() as $name => $value) { - if ($value === null) $strings[] = $name; - else $strings[] = sprintf('%s="%s"', $name, static::sanitizeAttribute($value)); + if (is_string($value)) { + $strings[] = sprintf('%s="%s"', $name, static::sanitizeAttribute($value)); + } elseif ($value) { + $strings[] = $name; + } } return $strings; } diff --git a/tests/Containers/DocumentTags/HeadTagTest.php b/tests/Containers/DocumentTags/HeadTagTest.php index 4645166..9a7e989 100644 --- a/tests/Containers/DocumentTags/HeadTagTest.php +++ b/tests/Containers/DocumentTags/HeadTagTest.php @@ -12,11 +12,4 @@ class HeadTagTest extends TestCase $this->assertInstanceOf(TitleTagInterface::class, $head->title()); $this->assertEquals($head, $head->title()->parent()); } - - public function testNoTitleDetach() - { - $head = new HeadTag; - $this->expectExceptionMessage('Not allowed to detach TitleTag'); - $head->title()->detach(); - } } diff --git a/tests/Containers/FragmentTest.php b/tests/Containers/FragmentTest.php index 793a281..5f528f4 100644 --- a/tests/Containers/FragmentTest.php +++ b/tests/Containers/FragmentTest.php @@ -30,42 +30,17 @@ class FragmentTest extends TestCase $div2 = $this->tag('div'); // adding div1 to fragment sets its fragment $fragment->addChild($div1); - $this->assertEquals($fragment, $div1->document()); + $this->assertEquals($fragment, $div1->parentDocument()); // adding div2 to div1 sets its document $div1->addChild($div2); - $this->assertEquals($fragment, $div2->document()); + $this->assertEquals($fragment, $div2->parentDocument()); + // div2's parent tag should be div1 + $this->assertEquals($div1, $div2->parentTag()); + // div1 should not have a parent tag + $this->assertNull($div1->parentTag()); return $fragment; } - /** @depends clone testNestingDocument */ - public function testDetaching(Fragment $fragment): void - { - /** @var AbstractContainerTag */ - $div1 = $fragment->children()[0]; - /** @var AbstractContainerTag */ - $div2 = $div1->children()[0]; - // add a span and verify it has the right parent - $span = $this->tag('span'); - $div2->addChild($span); - $this->assertEquals($fragment, $span->document()); - // detach and check document/parent of all nodes - $div1->detach(); - $this->assertNull($div1->document()); - $this->assertNull($div1->parent()); - $this->assertNull($div2->document()); - $this->assertEquals($div1, $div2->parent()); - $this->assertNull($span->document()); - $this->assertEquals($div2, $span->parent()); - // try detaching again, to verify detaching a detached node does nothing - $div1->detach(); - $this->assertNull($div1->document()); - $this->assertNull($div1->parent()); - $this->assertNull($div2->document()); - $this->assertEquals($div1, $div2->parent()); - $this->assertNull($span->document()); - $this->assertEquals($div2, $span->parent()); - } - public function testMovingChild(): void { $fragment = new Fragment(['a', 'b']); @@ -83,9 +58,9 @@ class FragmentTest extends TestCase $div2->addChild('a'); // add child before a $div2->addChildBefore('b', 'a'); - $this->assertEquals($fragment, $div2->children()[0]->document()); + $this->assertEquals($fragment, $div2->children()[0]->parentDocument()); // add child after a $div2->addChildAfter('c', 'a'); - $this->assertEquals($fragment, $div2->children()[2]->document()); + $this->assertEquals($fragment, $div2->children()[2]->parentDocument()); } } diff --git a/tests/Containers/GenericHtmlDocumentTest.php b/tests/Containers/GenericHtmlDocumentTest.php index 75feb34..9c42210 100644 --- a/tests/Containers/GenericHtmlDocumentTest.php +++ b/tests/Containers/GenericHtmlDocumentTest.php @@ -22,10 +22,10 @@ class GenericHtmlDocumentTest extends TestCase $this->assertTrue($document->body() === $document->html()->body()); $this->assertTrue($document->head() === $document->html()->head()); // everything has the correct document - $this->assertEquals($document, $document->doctype()->document()); - $this->assertEquals($document, $document->html()->document()); - $this->assertEquals($document, $document->body()->document()); - $this->assertEquals($document, $document->head()->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 @@ -45,18 +45,4 @@ class GenericHtmlDocumentTest extends TestCase $document->__toString() ); } - - public function testNoDoctypeDetach(): void - { - $document = new GenericHtmlDocument; - $this->expectExceptionMessage('Cannot detach() a Node from a parent that is not a ContainerMutableInterface, use detachCopy() instead'); - $document->doctype()->detach(); - } - - public function testNoHtmlDetach(): void - { - $document = new GenericHtmlDocument; - $this->expectExceptionMessage('Cannot detach() a Node from a parent that is not a ContainerMutableInterface, use detachCopy() instead'); - $document->html()->detach(); - } } diff --git a/tests/Helpers/AttributesTest.php b/tests/Helpers/AttributesTest.php index 1d218ea..6652d61 100644 --- a/tests/Helpers/AttributesTest.php +++ b/tests/Helpers/AttributesTest.php @@ -10,18 +10,18 @@ class AttributesTest extends TestCase { $attributes = new Attributes(); $this->assertEquals([], $attributes->getArray()); - $attributes = new Attributes(['foo' => 'bar', 'baz' => null]); - $this->assertEquals(['baz' => null, 'foo' => 'bar'], $attributes->getArray()); + $attributes = new Attributes(['foo' => 'bar', 'baz' => true]); + $this->assertEquals(['baz' => true, 'foo' => 'bar'], $attributes->getArray()); return $attributes; } - public function testInvalidConstructionEmptyName(): Attributes + public function testInvalidConstructionEmptyName(): void { $this->expectExceptionMessage('Attribute name must be specified when setting'); $attributes = new Attributes(['' => 'foo']); } - public function testInvalidConstructionInvalidName(): Attributes + public function testInvalidConstructionInvalidName(): void { $this->expectExceptionMessage('Invalid character in attribute name'); $attributes = new Attributes(['a=b' => 'foo']); @@ -34,7 +34,7 @@ class AttributesTest extends TestCase { $attributes['a'] = 'b'; $this->assertEquals('b', $attributes['a']); - $this->assertEquals(['a' => 'b', 'baz' => null, 'foo' => 'bar'], $attributes->getArray()); + $this->assertEquals(['a' => 'b', 'baz' => true, 'foo' => 'bar'], $attributes->getArray()); unset($attributes['baz']); $this->assertEquals(['a' => 'b', 'foo' => 'bar'], $attributes->getArray()); } @@ -44,9 +44,16 @@ class AttributesTest extends TestCase */ public function testOffsetExists(Attributes $attributes): void { + // test with a regular string $this->assertFalse(isset($attributes['a'])); $attributes['a'] = 'b'; $this->assertTrue(isset($attributes['a'])); + // test with an empty string + $attributes['b'] = ''; + $this->assertTrue(isset($attributes['b'])); + // test with a null value + $attributes['c'] = null; + $this->assertFalse(isset($attributes['c'])); } /** diff --git a/tests/Html5/Tags/ScriptTagTest.php b/tests/Html5/Tags/ScriptTagTest.php new file mode 100644 index 0000000..0838378 --- /dev/null +++ b/tests/Html5/Tags/ScriptTagTest.php @@ -0,0 +1,20 @@ +assertAttributeHelperMethods('crossorigin', ScriptTag::class); + $this->assertAttributeHelperMethods('integrity', ScriptTag::class); + $this->assertAttributeHelperMethods('integrity', ScriptTag::class); + $this->assertAttributeHelperMethods('nonce', ScriptTag::class); + $this->assertAttributeHelperMethods('referrerpolicy', ScriptTag::class); + $this->assertAttributeHelperMethods('src', ScriptTag::class); + $this->assertAttributeHelperMethods('type', ScriptTag::class); + $this->assertBooleanAttributeHelperMethods('async', ScriptTag::class); + $this->assertBooleanAttributeHelperMethods('defer', ScriptTag::class); + $this->assertBooleanAttributeHelperMethods('nomodule', ScriptTag::class); + } +} diff --git a/tests/Html5/Tags/TagTestCase.php b/tests/Html5/Tags/TagTestCase.php index 8365d6e..201503d 100644 --- a/tests/Html5/Tags/TagTestCase.php +++ b/tests/Html5/Tags/TagTestCase.php @@ -28,6 +28,23 @@ abstract class TagTestCase extends TestCase $this->assertNull(call_user_func([$tag, $getFn])); } + protected function assertBooleanAttributeHelperMethods(string $attribute, string $class): void + { + /** @var TagInterface */ + $tag = new $class; + $words = explode('-', $attribute); + $setFn = 'set' . implode('', array_map('ucfirst', $words)); + $getFn = array_shift($words) . implode('', array_map('ucfirst', $words)); + // test setting true + call_user_func([$tag, $setFn], true); + $this->assertTagRendersBooleanAttribute($tag, $attribute, true); + $this->assertTrue(call_user_func([$tag, $getFn])); + // test setting false + call_user_func([$tag, $setFn], false); + $this->assertTagRendersBooleanAttribute($tag, $attribute, false); + $this->assertFalse(call_user_func([$tag, $getFn])); + } + protected function assertTagRendersAttribute(TagInterface $tag, string $attribute, string $value) { if ($tag instanceof ContainerInterface || $tag instanceof ContentTagInterface) { @@ -38,10 +55,43 @@ abstract class TagTestCase extends TestCase ); } else { $this->assertEquals( - sprintf('<%s %s="%s"/>', $tag->tag(), $attribute, $value), + sprintf('<%s %s="%s">', $tag->tag(), $attribute, $value), $tag->__toString(), sprintf('Unexpected rendering of %s value %s is in %s tag', $attribute, $value, $tag->tag()) ); } } + + protected function assertTagRendersBooleanAttribute(TagInterface $tag, string $attribute, bool $value) + { + if ($tag instanceof ContainerInterface || $tag instanceof ContentTagInterface) { + if ($value) { + $this->assertEquals( + sprintf('<%s %s>', $tag->tag(), $attribute, $tag->tag()), + $tag->__toString(), + sprintf('Unexpected rendering of %s value %s is in %s tag', $attribute, $value ? 'true' : 'false', $tag->tag()) + ); + } else { + $this->assertEquals( + sprintf('<%s>', $tag->tag(), $tag->tag()), + $tag->__toString(), + sprintf('Unexpected rendering of %s value %s is in %s tag', $attribute, $value ? 'true' : 'false', $tag->tag()) + ); + } + } else { + if ($value) { + $this->assertEquals( + sprintf('<%s %s>', $tag->tag(), $attribute), + $tag->__toString(), + sprintf('Unexpected rendering of %s value %s is in %s tag', $attribute, $value ? 'true' : 'false', $tag->tag()) + ); + } else { + $this->assertEquals( + sprintf('<%s>', $tag->tag()), + $tag->__toString(), + sprintf('Unexpected rendering of %s value %s is in %s tag', $attribute, $value ? 'true' : 'false', $tag->tag()) + ); + } + } + } } diff --git a/tests/Nodes/CommentTest.php b/tests/Nodes/CommentTest.php index ba34766..2208966 100644 --- a/tests/Nodes/CommentTest.php +++ b/tests/Nodes/CommentTest.php @@ -13,4 +13,12 @@ class CommentTest extends TestCase $this->assertEquals('', new Comment('foo-bar')); $this->assertNotEquals('', new Comment('foo--bar')); } + + public function testModification(): void + { + $comment = new Comment('foo'); + $this->assertEquals('foo', $comment->value()); + $comment->setValue('bar'); + $this->assertEquals('bar', $comment->value()); + } } diff --git a/tests/Nodes/TextTest.php b/tests/Nodes/TextTest.php index af80b09..ccde882 100644 --- a/tests/Nodes/TextTest.php +++ b/tests/Nodes/TextTest.php @@ -12,4 +12,12 @@ class TextTest extends TestCase $this->assertEquals('foo', new Text('foo')); $this->assertEquals('foo', new Text('foo')); } + + public function testModification(): void + { + $text = new Text('foo'); + $this->assertEquals('foo', $text->value()); + $text->setValue('bar'); + $this->assertEquals('bar', $text->value()); + } } diff --git a/tests/Nodes/UnsanitizedTextTest.php b/tests/Nodes/UnsanitizedTextTest.php index dd4555c..0e67072 100644 --- a/tests/Nodes/UnsanitizedTextTest.php +++ b/tests/Nodes/UnsanitizedTextTest.php @@ -12,4 +12,12 @@ class UnsanitizedTextTest extends TestCase $this->assertEquals('foo', new UnsanitizedText('foo')); $this->assertEquals('foo', new UnsanitizedText('foo')); } + + public function testModification(): void + { + $text = new UnsanitizedText('foo'); + $this->assertEquals('foo', $text->value()); + $text->setValue('bar'); + $this->assertEquals('bar', $text->value()); + } } diff --git a/tests/Tags/AbstractContainerTagTest.php b/tests/Tags/AbstractContainerTagTest.php index f797b1c..8274ff7 100644 --- a/tests/Tags/AbstractContainerTagTest.php +++ b/tests/Tags/AbstractContainerTagTest.php @@ -34,6 +34,15 @@ class AbstractContainerTagTest extends TestCase return $div; } + public function testBooleanAttributes(): void + { + $tag = $this->tag('div'); + $tag->attributes()['a'] = true; + $tag->attributes()['b'] = false; + $tag->attributes()['c'] = null; + $this->assertEquals('
', $tag->__toString()); + } + /** @depends clone testDIV */ public function testMoreNesting(AbstractContainerTag $div): AbstractContainerTag { @@ -79,16 +88,6 @@ class AbstractContainerTagTest extends TestCase $this->assertInstanceOf(UnsanitizedText::class, $div->children()[2]); } - /** @depends clone testMoreNesting */ - public function testDetach(AbstractContainerTag $div): void - { - /** @var AbstractContainerTag */ - $span = $div->children()[0]; - $span->detach(); - $this->assertEquals('
', $div->__toString()); - $this->assertNull($span->parent()); - } - /** @depends clone testMoreNesting */ public function testDetachCopy(AbstractContainerTag $div): void { diff --git a/tests/Tags/AbstractTagTest.php b/tests/Tags/AbstractTagTest.php index b6b1920..f799827 100644 --- a/tests/Tags/AbstractTagTest.php +++ b/tests/Tags/AbstractTagTest.php @@ -19,7 +19,7 @@ class AbstractTagTest extends TestCase public function testBR(): AbstractTag { $br = $this->tag('br'); - $this->assertEquals('
', $br->__toString()); + $this->assertEquals('
', $br->__toString()); $this->assertInstanceOf(Classes::class, $br->classes()); $this->assertInstanceOf(Attributes::class, $br->attributes()); return $br; @@ -33,10 +33,10 @@ class AbstractTagTest extends TestCase $this->assertNull($tag->id()); $tag->setID('foo'); $this->assertEquals('foo', $tag->id()); - $this->assertEquals('
', $tag->__toString()); + $this->assertEquals('
', $tag->__toString()); $tag->setID(null); $this->assertNull($tag->id()); - $this->assertEquals('
', $tag->__toString()); + $this->assertEquals('
', $tag->__toString()); } /** @@ -46,12 +46,21 @@ class AbstractTagTest extends TestCase { $tag->attributes()['b'] = 'c'; $tag->attributes()['a'] = 'b'; - $this->assertEquals('
', $tag->__toString()); + $this->assertEquals('
', $tag->__toString()); unset($tag->attributes()['a']); - $this->assertEquals('
', $tag->__toString()); + $this->assertEquals('
', $tag->__toString()); $tag->classes()->add('some-class'); $tag->styles()['style'] = 'value'; - $this->assertEquals('
', $tag->__toString()); + $this->assertEquals('
', $tag->__toString()); + } + + public function testBooleanAttributes(): void + { + $tag = $this->tag('br'); + $tag->attributes()['a'] = true; + $tag->attributes()['b'] = false; + $tag->attributes()['c'] = null; + $this->assertEquals('
', $tag->__toString()); } /**