diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
new file mode 100644
index 0000000..4289a62
--- /dev/null
+++ b/.github/workflows/tests.yaml
@@ -0,0 +1,17 @@
+name: Tests and analysis
+on:
+ push:
+ branches-ignore: [main]
+jobs:
+ test-and-analyze:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: php-actions/composer@v6
+ with:
+ args: --ignore-platform-reqs
+ - uses: php-actions/phpunit@v3
+ - uses: php-actions/phpstan@v3
+ with:
+ memory_limit: 1G
+ args: --memory-limit 1G
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 3a9875b..82dac7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
-/vendor/
-composer.lock
+/vendor
+/composer.lock
+/.phpunit.result.cache
+/coverage
\ No newline at end of file
diff --git a/composer.json b/composer.json
index 03db9fc..f3e7561 100644
--- a/composer.json
+++ b/composer.json
@@ -1,9 +1,10 @@
{
"name": "byjoby/html-object-strings",
- "description": "Abstraction layer for constructing arbitrary HTML tags in PHP",
+ "description": "Abstraction layer for constructing arbitrary HTML tags and documents in PHP",
"type": "library",
"require": {
- "php": ">=7.1"
+ "php": ">=8.1",
+ "myclabs/deep-copy": "^1"
},
"license": "MIT",
"authors": [{
@@ -14,21 +15,20 @@
"prefer-stable": true,
"autoload": {
"psr-4": {
- "HtmlObjectStrings\\": "src/"
+ "ByJoby\\HTML\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
- "HtmlObjectStrings\\": "tests/"
+ "ByJoby\\HTML\\": "tests/"
}
},
"scripts": {
- "test": [
- "phpunit"
- ]
+ "test": "phpunit",
+ "stan": "phpstan"
},
"require-dev": {
- "phpunit/phpunit": "^7",
- "stevegrunwell/phpunit-markup-assertions": "^1.2"
+ "phpstan/phpstan": "^1.9",
+ "phpunit/phpunit": "^9.5"
}
}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..478362b
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,4 @@
+parameters:
+ level: max
+ paths:
+ - src
\ No newline at end of file
diff --git a/phpunit.xml b/phpunit.xml
index a671214..71de912 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -1,7 +1,30 @@
-
+
+
+
+
+
+
+
-
- tests
+
+ tests/
-
+
+
+ src
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/A.php b/src/A.php
deleted file mode 100644
index 51f7e5b..0000000
--- a/src/A.php
+++ /dev/null
@@ -1,9 +0,0 @@
- */
+ public function children(): array;
+}
diff --git a/src/ContainerMutableInterface.php b/src/ContainerMutableInterface.php
new file mode 100644
index 0000000..bfa8a27
--- /dev/null
+++ b/src/ContainerMutableInterface.php
@@ -0,0 +1,30 @@
+children());
+ }
+}
diff --git a/src/Containers/FragmentInterface.php b/src/Containers/FragmentInterface.php
new file mode 100644
index 0000000..f7b76bf
--- /dev/null
+++ b/src/Containers/FragmentInterface.php
@@ -0,0 +1,10 @@
+htmlInit();
- }
-
- protected function htmlInit()
- {
- $this->tag = static::TAG;
- $this->selfClosing = static::SELFCLOSING;
- }
-}
diff --git a/src/Helpers/Attributes.php b/src/Helpers/Attributes.php
new file mode 100644
index 0000000..3b36d06
--- /dev/null
+++ b/src/Helpers/Attributes.php
@@ -0,0 +1,92 @@
+
+ * @implements IteratorAggregate
+ */
+class Attributes implements IteratorAggregate, ArrayAccess
+{
+ /** @var array */
+ protected $array = [];
+ /** @var bool */
+ protected $sorted = true;
+ /** @var array */
+ protected $disallowed = [];
+
+ /**
+ * @param null|array $array
+ * @param array $disallowed
+ * @return void
+ */
+ public function __construct(null|array $array = null, $disallowed = [])
+ {
+ $this->disallowed = $disallowed;
+ if (!$array) return;
+ foreach ($array as $key => $value) {
+ $this[$key] = $value;
+ }
+ }
+
+ function offsetExists(mixed $offset): bool
+ {
+ $offset = static::sanitizeOffset($offset);
+ return isset($this->array[$offset]);
+ }
+
+ function offsetGet(mixed $offset): mixed
+ {
+ $offset = static::sanitizeOffset($offset);
+ return $this->array[$offset];
+ }
+
+ function offsetSet(mixed $offset, mixed $value): void
+ {
+ if (!$offset || !trim($offset)) throw new Exception('Attribute name must be specified when setting');
+ $offset = static::sanitizeOffset($offset);
+ if (in_array($offset, $this->disallowed)) throw new Exception('Setting attribute is disallowed');
+ if (!isset($this->array[$offset])) $this->sorted = false;
+ $this->array[$offset] = $value;
+ }
+
+ function offsetUnset(mixed $offset): void
+ {
+ $offset = static::sanitizeOffset($offset);
+ unset($this->array[$offset]);
+ }
+
+ /**
+ * @return array
+ */
+ function getArray(): array
+ {
+ if (!$this->sorted) {
+ ksort($this->array);
+ $this->sorted = true;
+ }
+ return $this->array;
+ }
+
+ function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->getArray());
+ }
+
+ protected static function sanitizeOffset(string $offset): string
+ {
+ $offset = trim($offset);
+ $offset = strtolower($offset);
+ if (preg_match('/[\t\n\f \/>"\'=]/', $offset)) throw new Exception('Invalid character in attribute name');
+ return $offset;
+ }
+}
diff --git a/src/Helpers/Classes.php b/src/Helpers/Classes.php
new file mode 100644
index 0000000..d53751d
--- /dev/null
+++ b/src/Helpers/Classes.php
@@ -0,0 +1,96 @@
+
+ */
+class Classes implements IteratorAggregate, Countable
+{
+ /** @var array */
+ protected $classes = [];
+ /** @var bool */
+ protected $sorted = true;
+
+ /**
+ * @param null|array|Traversable $array
+ */
+ public function __construct(null|array|Traversable $array = null, bool $no_exception = true)
+ {
+ if (!$array) return;
+ foreach ($array as $class) {
+ $this->add($class, $no_exception);
+ }
+ }
+
+ public function count(): int
+ {
+ return count($this->classes);
+ }
+
+ function getIterator(): Traversable
+ {
+ return new ArrayIterator($this->getArray());
+ }
+
+ /**
+ * @return array
+ */
+ function getArray(): array
+ {
+ if (!$this->sorted) {
+ sort($this->classes);
+ $this->sorted = true;
+ }
+ return $this->classes;
+ }
+
+ public function add(string|Stringable $class, bool $no_exception = false): static
+ {
+ try {
+ $class = static::sanitizeClassName($class, true);
+ } catch (\Throwable $th) {
+ if ($no_exception) return $this;
+ else throw $th;
+ }
+ if (!in_array($class, $this->classes)) {
+ $this->classes[] = $class;
+ $this->sorted = false;
+ }
+ return $this;
+ }
+
+ public function remove(string|Stringable $class): static
+ {
+ $class = static::sanitizeClassName($class);
+ $this->classes = array_values(array_filter(
+ $this->classes,
+ function (string|Stringable $e) use ($class): bool {
+ return $e != $class;
+ }
+ ));
+ return $this;
+ }
+
+ public function contains(string|Stringable $class): bool
+ {
+ $class = static::sanitizeClassName($class);
+ return in_array($class, $this->classes);
+ }
+
+ protected static function sanitizeClassName(string $class, bool $validate = false): string
+ {
+ $class = trim($class);
+ if ($validate && !preg_match('/^[_\-a-z][_\-a-z0-9]*$/i', $class)) throw new Exception('Invalid class name');
+ return $class;
+ }
+}
diff --git a/src/Helpers/Styles.php b/src/Helpers/Styles.php
new file mode 100644
index 0000000..7850596
--- /dev/null
+++ b/src/Helpers/Styles.php
@@ -0,0 +1,118 @@
+
+ */
+class Styles implements Countable, ArrayAccess, Stringable
+{
+ /** @var array */
+ protected $styles = [];
+ /** @var bool */
+ protected $sorted = true;
+
+ /**
+ * @param null|array|Traversable|null $classes
+ */
+ public function __construct(null|array|Traversable $classes = null)
+ {
+ if ($classes) {
+ foreach ($classes as $name => $value) {
+ $this[$name] = $value;
+ }
+ }
+ }
+
+ public function count(): int
+ {
+ return count($this->styles);
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ $offset = static::normalizePropertyName($offset);
+ if (!$offset) return false;
+ return isset($this->styles[$offset]);
+ }
+
+ public function offsetGet(mixed $offset): mixed
+ {
+ return @$this->styles[$offset];
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ $offset = static::normalizePropertyName($offset);
+ if (!$offset) return;
+ if ($value) $value = trim($value);
+ if (!$value) unset($this->styles[$offset]);
+ else {
+ if (!static::validate($offset, $value)) return;
+ if (!isset($this->styles[$offset])) $this->sorted = false;
+ $this->styles[$offset] = $value;
+ }
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ $offset = static::normalizePropertyName($offset);
+ if (!$offset) return;
+ unset($this->styles[$offset]);
+ }
+
+ /**
+ * @return array
+ */
+ public function getArray(): array
+ {
+ if (!$this->sorted) {
+ ksort($this->styles);
+ $this->sorted = true;
+ }
+ return $this->styles;
+ }
+
+ public function __toString(): string
+ {
+ $styles = [];
+ foreach ($this->getArray() as $key => $value) {
+ $styles[] = $key . ':' . $value;
+ }
+ return implode(';', $styles);
+ }
+
+ public static function normalizePropertyName(null|string $name): null|string
+ {
+ if (!$name) return null;
+ $name = trim(strtolower($name));
+ $name = preg_replace('/[^a-z\-]/', '', $name);
+ return $name;
+ }
+
+ public static function validate(null|string $property, null|string $value): bool
+ {
+ $property = static::normalizePropertyName($property);
+ if (!$property) return false;
+ if (!preg_match('/[a-z]/', $property)) return false;
+
+ if ($value) $value = trim($value);
+ if (!$value) return false;
+ if (str_contains($value, ';')) return false;
+ if (str_contains($value, ':')) return false;
+
+ return true;
+ }
+}
diff --git a/src/Input.php b/src/Input.php
deleted file mode 100644
index 5947538..0000000
--- a/src/Input.php
+++ /dev/null
@@ -1,30 +0,0 @@
-attr('type', static::TYPE);
- }
-
- protected function htmlContent()
- {
- return parent::htmlContent();
- }
-
- protected function htmlAttributes()
- {
- $attr = parent::htmlAttributes();
- if ($value = $this->htmlContent()) {
- $attr['value'] = $value;
- }
- return $attr;
- }
-}
diff --git a/src/NodeInterface.php b/src/NodeInterface.php
new file mode 100644
index 0000000..1e8f608
--- /dev/null
+++ b/src/NodeInterface.php
@@ -0,0 +1,23 @@
+',
+ str_replace(
+ '--', // regular hyphens
+ '‑‑', // non-breaking hyphens
+ $this->value
+ )
+ );
+ }
+}
diff --git a/src/Nodes/CommentInterface.php b/src/Nodes/CommentInterface.php
new file mode 100644
index 0000000..0405dce
--- /dev/null
+++ b/src/Nodes/CommentInterface.php
@@ -0,0 +1,11 @@
+value));
+ }
+}
diff --git a/src/Nodes/TextInterface.php b/src/Nodes/TextInterface.php
new file mode 100644
index 0000000..0abf881
--- /dev/null
+++ b/src/Nodes/TextInterface.php
@@ -0,0 +1,11 @@
+value;
+ }
+}
diff --git a/src/TagInterface.php b/src/TagInterface.php
deleted file mode 100644
index 3643634..0000000
--- a/src/TagInterface.php
+++ /dev/null
@@ -1,19 +0,0 @@
-hidden = $hidden;
- }
- return $this->hidden;
- }
-
- protected function htmlContent()
- {
- if (is_array($this->content)) {
- return implode(PHP_EOL, $this->content);
- } else {
- return $this->content;
- }
- }
-
- protected function htmlAttributes()
- {
- $attr = $this->attributes;
- if ($this->classes()) {
- $attr['class'] = implode(' ', $this->classes());
- }
- return $attr;
- }
-
- public function addClass(string $name)
- {
- if (!$name) {
- return;
- }
- $this->classes[] = $name;
- $this->classes = array_unique($this->classes);
- sort($this->classes);
- }
-
- public function hasClass(string $name) : bool
- {
- return in_array($name, $this->classes);
- }
-
- public function removeClass(string $name)
- {
- $this->classes = array_filter(
- $this->classes,
- function ($e) use ($name) {
- return $e != $name;
- }
- );
- sort($this->classes);
- }
-
- public function classes() : array
- {
- return $this->classes;
- }
-
- public function attr(string $name, $value = null)
- {
- if ($value === false) {
- unset($this->attributes[$name]);
- return null;
- }
- if ($value !== null) {
- $this->attributes[$name] = $value;
- }
- return @$this->attributes[$name];
- }
-
- public function data(string $name, $value = null)
- {
- return $this->attr("data-$name", $value);
- }
-
- public function string() : string
- {
- //output empty string if hidden
- if ($this->hidden()) {
- return '';
- }
- //build output
- $out = '';
- //build opening tag
- $out .= '<'.$this->tag;
- //build attributes
- if ($attr = $this->htmlAttributes()) {
- foreach ($attr as $key => $value) {
- if ($value === null) {
- $out .= " $key";
- } else {
- $value = htmlspecialchars($value);
- $out .= " $key=\"$value\"";
- }
- }
- }
- //continue t close opening tag and add content and closing tag if needed
- if ($this->selfClosing) {
- $out .= ' />';
- } else {
- $out .= '>';
- //build content
- $out .= $this->htmlContent();
- //build closing tag
- $out .= ''.$this->tag.'>';
- }
- return $out;
- }
-
- public function __toString()
- {
- return $this->string();
- }
-}
diff --git a/src/Tags/AbstractContainerTag.php b/src/Tags/AbstractContainerTag.php
new file mode 100644
index 0000000..613d9ba
--- /dev/null
+++ b/src/Tags/AbstractContainerTag.php
@@ -0,0 +1,20 @@
+detach();
+ $child->setParent($this);
+ $child->setDocument($this->document());
+ }
+ 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 ($child instanceof NodeInterface) 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 {
+ return $this;
+ }
+
+ public function addChildAfter(
+ NodeInterface|Stringable|string $new_child,
+ NodeInterface|Stringable|string $after_child,
+ bool $skip_sanitize = false
+ ): static {
+ return $this;
+ }
+}
diff --git a/src/Traits/ContainerTagTrait.php b/src/Traits/ContainerTagTrait.php
new file mode 100644
index 0000000..5826fe7
--- /dev/null
+++ b/src/Traits/ContainerTagTrait.php
@@ -0,0 +1,23 @@
+', implode(' ', $this->openingTagStrings()));
+ $closingTag = sprintf('%s>', $this->tag());
+ if (!$this->children()) return $openingTag . $closingTag;
+ else return implode(
+ PHP_EOL,
+ [
+ $openingTag,
+ implode(PHP_EOL, $this->children()),
+ $closingTag
+ ]
+ );
+ }
+}
diff --git a/src/Traits/ContainerTrait.php b/src/Traits/ContainerTrait.php
new file mode 100644
index 0000000..ec81ba0
--- /dev/null
+++ b/src/Traits/ContainerTrait.php
@@ -0,0 +1,17 @@
+ */
+ protected $children = [];
+
+ /** @return array */
+ public function children(): array
+ {
+ return $this->children;
+ }
+}
diff --git a/src/Traits/NodeTrait.php b/src/Traits/NodeTrait.php
new file mode 100644
index 0000000..d4169cb
--- /dev/null
+++ b/src/Traits/NodeTrait.php
@@ -0,0 +1,69 @@
+parent;
+ }
+
+ public function setParent(null|NodeInterface $parent): static
+ {
+ $this->parent = $parent;
+ return $this;
+ }
+
+ public function document(): null|DocumentInterface
+ {
+ return $this->document;
+ }
+
+ public function setDocument(null|DocumentInterface $document): static
+ {
+ $this->document = $document;
+ if ($this instanceof ContainerInterface) {
+ foreach ($this->children() as $child) {
+ $child->setDocument($document);
+ }
+ }
+ return $this;
+ }
+
+ public function detach(): static
+ {
+ if (!$this->parent()) return $this;
+ if ($this->parent() instanceof ContainerMutableInterface) {
+ $this->parent()->removeChild($this);
+ } else {
+ throw new Exception('Cannot detach() a Node from a parent that is not a ContainerMutableInterface, use detachCopy() instead');
+ }
+ $this->setParent(null);
+ $this->setDocument(null);
+ return $this;
+ }
+
+ public function detachCopy(): static
+ {
+ static $copier;
+ $copier = $copier ?? new DeepCopy();
+ return ($copier->copy($this))
+ ->setParent(null)
+ ->setDocument(null);
+ }
+}
diff --git a/src/Traits/TagTrait.php b/src/Traits/TagTrait.php
new file mode 100644
index 0000000..0feca72
--- /dev/null
+++ b/src/Traits/TagTrait.php
@@ -0,0 +1,101 @@
+attributes = new Attributes(null, ['id', 'class', 'style']);
+ $this->classes = new Classes();
+ $this->styles = new Styles();
+ }
+
+ public function id(): null|string
+ {
+ return $this->id;
+ }
+
+ public function setID(null|string|Stringable $id): static
+ {
+ if ($id) $this->id = static::sanitizeID($id);
+ else $this->id = null;
+ return $this;
+ }
+
+ protected static function sanitizeID(string|Stringable $id): string
+ {
+ $id = trim($id);
+ if (!preg_match('/^[_\-a-z][_\-a-z0-9]*$/i', $id)) throw new Exception('Invalid ID name');
+ return $id;
+ }
+
+ public function attributes(): Attributes
+ {
+ return $this->attributes;
+ }
+
+ public function classes(): Classes
+ {
+ return $this->classes;
+ }
+
+ public function styles(): Styles
+ {
+ return $this->styles;
+ }
+
+ public function __toString(): string
+ {
+ return sprintf('<%s/>', implode(' ', $this->openingTagStrings()));
+ }
+
+ /**
+ * @return array
+ */
+ protected function openingTagStrings(): array
+ {
+ $strings = [$this->tag()];
+ if ($this->id) $strings[] = sprintf('id="%s"', $this->id);
+ if ($this->classes()->count()) {
+ $strings[] = sprintf('class="%s"', implode(' ', $this->classes()->getArray()));
+ }
+ if ($this->styles()->count()) {
+ $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));
+ }
+ return $strings;
+ }
+
+ protected static function sanitizeAttribute(string $value): string
+ {
+ return str_replace(
+ ['<', '>', '&', '"'],
+ ['<', '>', '&', '"'],
+ $value
+ );
+ }
+}
diff --git a/tests/GenericTagTest.php b/tests/GenericTagTest.php
deleted file mode 100644
index 83faf26..0000000
--- a/tests/GenericTagTest.php
+++ /dev/null
@@ -1,79 +0,0 @@
-assertEquals([], $h->classes());
- $this->assertFalse($h->hasClass('foo'));
- //adding a class
- $h->addClass('foo');
- //should now exist
- $this->assertEquals(['foo'], $h->classes());
- $this->assertTrue($h->hasClass('foo'));
- //adding a second time shouldn't change anything
- $h->addClass('foo');
- $this->assertEquals(['foo'], $h->classes());
- $this->assertTrue($h->hasClass('foo'));
- //adding another class
- $h->addClass('bar');
- //should now exist, and classes should be in alphabetical order
- $this->assertEquals(['bar','foo'], $h->classes());
- $this->assertTrue($h->hasClass('bar'));
- //removing a class
- $h->addClass('abc');
- $h->removeClass('bar');
- //bar should now not exist, and classes should be in alphabetical order
- $this->assertEquals(['abc','foo'], $h->classes());
- $this->assertFalse($h->hasClass('bar'));
- }
-
- public function testDataAndAttributeManagement()
- {
- /*
- This section tests the basic getting/setting of attributes
- */
- $h = new GenericTag();
- //set and get an attribute
- $h->attr('foo', 'bar');
- $this->assertEquals('bar', $h->attr('foo'));
- //set and get data
- $h->data('foo', 'baz');
- $this->assertEquals('baz', $h->data('foo'));
- $this->assertEquals('baz', $h->attr('data-foo'));
- $this->assertEquals('bar', $h->attr('foo'));
- }
-
- public function testMarkupOutput()
- {
- /*
- Test that output has the correct attributes and classes for what was
- configured into the object
- */
- $h = new GenericTag();
- $h->tag = 'div';
- $h->selfClosing = false;
- $h->content = 'markup content';
- $h->attr('id', 'h');
- $h->data('foo', 'bar');
- $h->addClass('class-foo');
- $h->addClass('class-bar');
- //should be a div tag
- $this->assertContainsSelector('div', "$h");
- $this->assertContainsSelector('div#h', "$h");
- $this->assertContainsSelector('div[data-foo="bar"]', "$h");
- $this->assertContainsSelector('div.class-foo.class-bar', "$h");
- }
-}
diff --git a/tests/Helpers/AttributesTest.php b/tests/Helpers/AttributesTest.php
new file mode 100644
index 0000000..732b4a9
--- /dev/null
+++ b/tests/Helpers/AttributesTest.php
@@ -0,0 +1,59 @@
+assertEquals([], $attributes->getArray());
+ $attributes = new Attributes(['foo' => 'bar', 'baz' => null]);
+ $this->assertEquals(['baz' => null, 'foo' => 'bar'], $attributes->getArray());
+ return $attributes;
+ }
+
+ public function testInvalidConstructionEmptyName(): Attributes
+ {
+ $this->expectExceptionMessage('Attribute name must be specified when setting');
+ $attributes = new Attributes(['' => 'foo']);
+ }
+
+ public function testInvalidConstructionInvalidName(): Attributes
+ {
+ $this->expectExceptionMessage('Invalid character in attribute name');
+ $attributes = new Attributes(['a=b' => 'foo']);
+ }
+
+ /**
+ * @depends clone testConstruction
+ */
+ public function testSetAndUnset(Attributes $attributes): void
+ {
+ $attributes['a'] = 'b';
+ $this->assertEquals('b', $attributes['a']);
+ $this->assertEquals(['a' => 'b', 'baz' => null, 'foo' => 'bar'], $attributes->getArray());
+ unset($attributes['baz']);
+ $this->assertEquals(['a' => 'b', 'foo' => 'bar'], $attributes->getArray());
+ }
+
+ /**
+ * @depends clone testConstruction
+ */
+ public function testInvalidSetEmptyName(Attributes $attributes): void
+ {
+ $this->expectExceptionMessage('Attribute name must be specified when setting');
+ $attributes[] = 'b';
+ }
+
+ /**
+ * @depends clone testConstruction
+ */
+ public function testInvalidSetInvalidName(Attributes $attributes): void
+ {
+ $this->expectExceptionMessage('Invalid character in attribute name');
+ $attributes['>'] = 'b';
+ }
+}
diff --git a/tests/Helpers/ClassesTest.php b/tests/Helpers/ClassesTest.php
new file mode 100644
index 0000000..12853ab
--- /dev/null
+++ b/tests/Helpers/ClassesTest.php
@@ -0,0 +1,40 @@
+assertEquals([], $classes->getArray());
+ $classes = new Classes(['a', 'c', ' a ', 'b', '!']);
+ $this->assertEquals(['a', 'b', 'c'], $classes->getArray());
+ return $classes;
+ }
+
+ public function testInvalidConstruction()
+ {
+ $this->expectExceptionMessage('Invalid class name');
+ $classes = new Classes(['a', 'c', ' a ', 'b', '!'], false);
+ }
+
+ /**
+ * @depends clone testConstruction
+ */
+ public function testAddRemove(Classes $classes): void
+ {
+ $classes->add('d');
+ $this->assertEquals(['a', 'b', 'c', 'd'], $classes->getArray());
+ $classes->add('-d');
+ $this->assertEquals(['-d', 'a', 'b', 'c', 'd'], $classes->getArray());
+ $classes->add('_A');
+ $this->assertEquals(['-d', '_A', 'a', 'b', 'c', 'd'], $classes->getArray());
+ $classes->remove('b');
+ $this->assertEquals(['-d', '_A', 'a', 'c', 'd'], $classes->getArray());
+ $this->expectExceptionMessage('Invalid class name');
+ $classes->add('0a');
+ }
+}
diff --git a/tests/Helpers/StylesTest.php b/tests/Helpers/StylesTest.php
new file mode 100644
index 0000000..b726bc7
--- /dev/null
+++ b/tests/Helpers/StylesTest.php
@@ -0,0 +1,45 @@
+assertTrue(Styles::validate('foo', 'bar'));
+ $this->assertFalse(Styles::validate('foo', ''));
+ $this->assertFalse(Styles::validate('foo', ' '));
+ $this->assertFalse(Styles::validate('foo', null));
+ $this->assertTrue(Styles::validate(' -foo', 'bar'));
+ $this->assertTrue(Styles::validate(' foo ', 'bar'));
+ $this->assertFalse(Styles::validate('', 'bar'));
+ $this->assertFalse(Styles::validate('-', 'bar'));
+ $this->assertFalse(Styles::validate(' ', 'bar'));
+ $this->assertFalse(Styles::validate(null, 'bar'));
+ }
+
+ /**
+ * @depends testValidate
+ */
+ public function testConstruction(): Styles
+ {
+ $styles = new Styles();
+ $this->assertEquals([], $styles->getArray());
+ $styles = new Styles(['foo' => 'bar', 'baz' => null]);
+ $this->assertEquals(['foo' => 'bar'], $styles->getArray());
+ return $styles;
+ }
+
+ /**
+ * @depends clone testConstruction
+ */
+ public function testGettingAndSetting(Styles $styles): void
+ {
+ $styles['a'] = 'b';
+ $this->assertEquals('b', $styles['a']);
+ unset($styles['foo']);
+ $this->assertNull($styles['foo']);
+ }
+}
diff --git a/tests/Nodes/CommentTest.php b/tests/Nodes/CommentTest.php
new file mode 100644
index 0000000..ba34766
--- /dev/null
+++ b/tests/Nodes/CommentTest.php
@@ -0,0 +1,16 @@
+assertEquals('', new Comment(''));
+ $this->assertEquals('', new Comment('foo'));
+ $this->assertEquals('', new Comment('foo-bar'));
+ $this->assertNotEquals('', new Comment('foo--bar'));
+ }
+}
diff --git a/tests/Nodes/TextTest.php b/tests/Nodes/TextTest.php
new file mode 100644
index 0000000..af80b09
--- /dev/null
+++ b/tests/Nodes/TextTest.php
@@ -0,0 +1,15 @@
+assertEquals('', new Text(''));
+ $this->assertEquals('foo', new Text('foo'));
+ $this->assertEquals('foo', new Text('foo'));
+ }
+}
diff --git a/tests/Nodes/UnsanitizedTextTest.php b/tests/Nodes/UnsanitizedTextTest.php
new file mode 100644
index 0000000..dd4555c
--- /dev/null
+++ b/tests/Nodes/UnsanitizedTextTest.php
@@ -0,0 +1,15 @@
+assertEquals('', new UnsanitizedText(''));
+ $this->assertEquals('foo', new UnsanitizedText('foo'));
+ $this->assertEquals('foo', new UnsanitizedText('foo'));
+ }
+}
diff --git a/tests/Tags/AbstractContainerTagTest.php b/tests/Tags/AbstractContainerTagTest.php
new file mode 100644
index 0000000..189d588
--- /dev/null
+++ b/tests/Tags/AbstractContainerTagTest.php
@@ -0,0 +1,61 @@
+getMockForAbstractClass(AbstractContainerTag::class);
+ $div->method('tag')->will($this->returnValue('div'));
+ $this->assertEquals('', $div->__toString());
+ $span = $this->getMockForAbstractClass(AbstractContainerTag::class);
+ $span->method('tag')->will($this->returnValue('span'));
+ $div->addChild($span);
+ $div->attributes()['a'] = 'b';
+ $this->assertEquals(
+ implode(PHP_EOL, [
+ '',
+ '',
+ '
',
+ ]),
+ $div->__toString()
+ );
+ $this->assertEquals($div, $span->parent());
+ return $div;
+ }
+
+ /** @depends clone testDIV */
+ public function testMoreNesting(AbstractContainerTag $div): AbstractContainerTag
+ {
+ $span1 = $div->children()[0];
+ $span2 = $this->getMockForAbstractClass(AbstractContainerTag::class);
+ $span2->method('tag')->will($this->returnValue('span'));
+ $span1->addChild($span2);
+ $this->assertEquals(
+ implode(PHP_EOL, [
+ '',
+ '',
+ '',
+ '',
+ '
',
+ ]),
+ $div->__toString()
+ );
+ return $div;
+ }
+
+ /** @depends clone testMoreNesting */
+ public function testDetach(AbstractContainerTag $div): void
+ {
+ $span1 = $div->children()[0];
+ $span2 = $span1->children()[0];
+ $span1->detach();
+ $this->assertEquals('', $div->__toString());
+ $this->assertNull($span1->parent());
+ }
+}
diff --git a/tests/Tags/AbstractTagTest.php b/tests/Tags/AbstractTagTest.php
new file mode 100644
index 0000000..a33c067
--- /dev/null
+++ b/tests/Tags/AbstractTagTest.php
@@ -0,0 +1,73 @@
+getMockForAbstractClass(AbstractTag::class);
+ $br->method('tag')->will($this->returnValue('br'));
+ $this->assertEquals('
', $br->__toString());
+ $this->assertInstanceOf(Classes::class, $br->classes());
+ $this->assertInstanceOf(Attributes::class, $br->attributes());
+ return $br;
+ }
+
+ /**
+ * @depends clone testBR
+ */
+ public function testID(AbstractTag $tag): void
+ {
+ $this->assertNull($tag->id());
+ $tag->setID('foo');
+ $this->assertEquals('foo', $tag->id());
+ $this->assertEquals('
', $tag->__toString());
+ $tag->setID(null);
+ $this->assertNull($tag->id());
+ $this->assertEquals('
', $tag->__toString());
+ }
+
+ /**
+ * @depends clone testBR
+ */
+ public function testAttributes(AbstractTag $tag): void
+ {
+ $tag->attributes()['b'] = 'c';
+ $tag->attributes()['a'] = 'b';
+ $this->assertEquals('
', $tag->__toString());
+ unset($tag->attributes()['a']);
+ $this->assertEquals('
', $tag->__toString());
+ }
+
+ /**
+ * @depends clone testBR
+ */
+ public function testSettingIDException(AbstractTag $tag): void
+ {
+ $this->expectExceptionMessage('Setting attribute is disallowed');
+ $tag->attributes()['id'] = 'foo';
+ }
+
+ /**
+ * @depends clone testBR
+ */
+ public function testSettingClassException(AbstractTag $tag): void
+ {
+ $this->expectExceptionMessage('Setting attribute is disallowed');
+ $tag->attributes()['class'] = 'foo';
+ }
+
+ /**
+ * @depends clone testBR
+ */
+ public function testSettingStyleException(AbstractTag $tag): void
+ {
+ $this->expectExceptionMessage('Setting attribute is disallowed');
+ $tag->attributes()['style'] = 'foo';
+ }
+}