initial commit of new version

This commit is contained in:
Joby 2022-11-30 07:55:35 -07:00
parent 1dd277dc0b
commit 0af465fe47
48 changed files with 1285 additions and 301 deletions

17
.github/workflows/tests.yaml vendored Normal file
View file

@ -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

6
.gitignore vendored
View file

@ -1,2 +1,4 @@
/vendor/
composer.lock
/vendor
/composer.lock
/.phpunit.result.cache
/coverage

View file

@ -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"
}
}

4
phpstan.neon Normal file
View file

@ -0,0 +1,4 @@
parameters:
level: max
paths:
- src

View file

@ -1,7 +1,30 @@
<phpunit>
<?xml version="1.0" encoding="UTF-8"?>
<phpunit
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
colors="true"
executionOrder="random"
failOnWarning="true"
failOnRisky="true"
failOnEmptyTestSuite="true"
beStrictAboutOutputDuringTests="true"
verbose="true">
<php>
<ini name="display_errors" value="On" />
<ini name="error_reporting" value="-1" />
<ini name="xdebug.mode" value="coverage" />
</php>
<testsuites>
<testsuite name="All Tests">
<directory>tests</directory>
<testsuite name="Tests">
<directory>tests/</directory>
</testsuite>
</testsuites>
</phpunit>
<coverage>
<include>
<directory suffix=".php">src</directory>
</include>
<report>
<html outputDirectory="coverage" lowUpperBound="50" highLowerBound="90" />
</report>
</coverage>
</phpunit>

View file

@ -1,9 +0,0 @@
<?php
/* HTML Object Strings | https://gitlab.com/byjoby/html-object-strings | MIT License */
namespace HtmlObjectStrings;
class A extends GenericTag
{
const TAG = 'a';
const SELFCLOSING = false;
}

View file

@ -0,0 +1,12 @@
<?php
namespace ByJoby\HTML;
use Stringable;
use Traversable;
interface ContainerInterface extends Stringable
{
/** @return array<int,NodeInterface> */
public function children(): array;
}

View file

@ -0,0 +1,30 @@
<?php
namespace ByJoby\HTML;
use Stringable;
interface ContainerMutableInterface extends ContainerInterface
{
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;
}

View file

@ -0,0 +1,11 @@
<?php
namespace ByJoby\HTML\Containers;
use ByJoby\HTML\ContainerInterface;
use ByJoby\HTML\Traits\ContainerTrait;
abstract class AbstractDocument implements DocumentInterface
{
use ContainerTrait;
}

View file

@ -0,0 +1,17 @@
<?php
namespace ByJoby\HTML\Containers;
use ByJoby\HTML\ContainerInterface;
use ByJoby\HTML\Containers\DocumentTags\BodyTagInterface;
use ByJoby\HTML\Containers\DocumentTags\DoctypeInterface;
use ByJoby\HTML\Containers\DocumentTags\HeadTagInterface;
use ByJoby\HTML\Containers\DocumentTags\HtmlTagInterface;
interface DocumentInterface extends ContainerInterface
{
public function doctype(): DoctypeInterface;
public function html(): HtmlTagInterface;
public function head(): HeadTagInterface;
public function body(): BodyTagInterface;
}

View file

@ -0,0 +1,9 @@
<?php
namespace ByJoby\HTML\Containers\DocumentTags;
use ByJoby\HTML\Tags\ContainerTagInterface;
interface BodyTagInterface extends ContainerTagInterface
{
}

View file

@ -0,0 +1,9 @@
<?php
namespace ByJoby\HTML\Containers\DocumentTags;
use PhpParser\Node;
interface DoctypeInterface extends Node
{
}

View file

@ -0,0 +1,10 @@
<?php
namespace ByJoby\HTML\Containers\DocumentTags;
use ByJoby\HTML\Tags\ContainerTagInterface;
interface HeadTagInterface extends ContainerTagInterface
{
public function title(): TitleTagInterface;
}

View file

@ -0,0 +1,12 @@
<?php
namespace ByJoby\HTML\Containers\DocumentTags;
use ByJoby\HTML\ContainerInterface;
use ByJoby\HTML\Tags\TagInterface;
interface HtmlTagInterface extends ContainerInterface, TagInterface
{
public function head(): HeadTagInterface;
public function body(): BodyTagInterface;
}

View file

@ -0,0 +1,11 @@
<?php
namespace ByJoby\HTML\Containers\DocumentTags;
use ByJoby\HTML\Tags\TagInterface;
interface TitleTagInterface extends TagInterface
{
public function title(): string;
public function setTitle(string $title): static;
}

View file

@ -0,0 +1,17 @@
<?php
namespace ByJoby\HTML\Containers;
use ByJoby\HTML\Traits\ContainerMutableTrait;
use ByJoby\HTML\Traits\ContainerTrait;
class Fragment implements FragmentInterface
{
use ContainerTrait;
use ContainerMutableTrait;
public function __toString(): string
{
return implode(PHP_EOL, $this->children());
}
}

View file

@ -0,0 +1,10 @@
<?php
namespace ByJoby\HTML\Containers;
use ByJoby\HTML\ContainerInterface;
use ByJoby\HTML\ContainerMutableInterface;
interface FragmentInterface extends ContainerMutableInterface
{
}

View file

@ -1,22 +0,0 @@
<?php
/* HTML Object Strings | https://gitlab.com/byjoby/html-object-strings | MIT License */
namespace HtmlObjectStrings;
class GenericTag implements TagInterface
{
use TagTrait;
const TAG = 'span';
const SELFCLOSING = false;
public function __construct()
{
$this->htmlInit();
}
protected function htmlInit()
{
$this->tag = static::TAG;
$this->selfClosing = static::SELFCLOSING;
}
}

View file

@ -0,0 +1,92 @@
<?php
namespace ByJoby\HTML\Helpers;
use ArrayAccess;
use ArrayIterator;
use Exception;
use IteratorAggregate;
use Stringable;
use Traversable;
/**
* Holds and validates a set of HTML attribute name/value pairs for use in tags.
*
* @implements ArrayAccess<string,null|string|Stringable>
* @implements IteratorAggregate<string,null|string|Stringable>
*/
class Attributes implements IteratorAggregate, ArrayAccess
{
/** @var array<string,null|string|Stringable> */
protected $array = [];
/** @var bool */
protected $sorted = true;
/** @var array<mixed,string> */
protected $disallowed = [];
/**
* @param null|array<string,null|string|Stringable> $array
* @param array<mixed,string> $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<string,null|string|Stringable>
*/
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;
}
}

96
src/Helpers/Classes.php Normal file
View file

@ -0,0 +1,96 @@
<?php
namespace ByJoby\HTML\Helpers;
use ArrayIterator;
use Countable;
use Exception;
use IteratorAggregate;
use Stringable;
use Traversable;
/**
* Holds and sorts a list of CSS classes, including validation and add/remove/contains methods.
*
* @implements IteratorAggregate<int,string|Stringable>
*/
class Classes implements IteratorAggregate, Countable
{
/** @var array<int,string|Stringable> */
protected $classes = [];
/** @var bool */
protected $sorted = true;
/**
* @param null|array<mixed,string|Stringable>|Traversable<mixed,string|Stringable> $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<int,string|Stringable>
*/
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;
}
}

118
src/Helpers/Styles.php Normal file
View file

@ -0,0 +1,118 @@
<?php
namespace ByJoby\HTML\Helpers;
use ArrayAccess;
use Countable;
use Stringable;
use Traversable;
/**
* A key difference in strategy between this class and Attributes or Classes is
* that it does not make significant validation attempts. CSS is an evolving
* language, and it would be a fool's errand to try and thoroughly validate it.
*
* To that end, this class is very accepting of not-obviously-malformed property
* names and values.
*
* @implements ArrayAccess<string,null|string|Stringable>
*/
class Styles implements Countable, ArrayAccess, Stringable
{
/** @var array<string|string> */
protected $styles = [];
/** @var bool */
protected $sorted = true;
/**
* @param null|array<string,string|Stringable>|Traversable<string,string|Stringable>|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<string,string>
*/
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;
}
}

View file

@ -1,30 +0,0 @@
<?php
/* HTML Object Strings | https://gitlab.com/byjoby/html-object-strings | MIT License */
namespace HtmlObjectStrings;
class Input extends GenericTag
{
const TAG = 'input';
const SELFCLOSING = true;
const TYPE = 'text';
protected function htmlInit()
{
parent::htmlInit();
$this->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;
}
}

23
src/NodeInterface.php Normal file
View file

@ -0,0 +1,23 @@
<?php
namespace ByJoby\HTML;
use ByJoby\HTML\Containers\DocumentInterface;
use Stringable;
interface NodeInterface extends Stringable
{
public function parent(): null|NodeInterface;
public function setParent(
null|NodeInterface $parent
): static;
public function document(): null|DocumentInterface;
public function setDocument(
null|DocumentInterface $parent
): static;
public function detach(): static;
}

27
src/Nodes/Comment.php Normal file
View file

@ -0,0 +1,27 @@
<?php
namespace ByJoby\HTML\Nodes;
use ByJoby\HTML\Traits\NodeTrait;
use Stringable;
class Comment implements CommentInterface
{
use NodeTrait;
public function __construct(protected Stringable|string $value)
{
}
public function __toString(): string
{
return sprintf(
'<!-- %s -->',
str_replace(
'--', // regular hyphens
'', // non-breaking hyphens
$this->value
)
);
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace ByJoby\HTML\Nodes;
use ByJoby\HTML\NodeInterface;
use Stringable;
interface CommentInterface extends NodeInterface
{
public function __construct(Stringable|string $value);
}

20
src/Nodes/Text.php Normal file
View file

@ -0,0 +1,20 @@
<?php
namespace ByJoby\HTML\Nodes;
use ByJoby\HTML\Traits\NodeTrait;
use Stringable;
class Text implements TextInterface
{
use NodeTrait;
public function __construct(protected Stringable|string $value)
{
}
public function __toString(): string
{
return htmlentities(strip_tags($this->value));
}
}

View file

@ -0,0 +1,11 @@
<?php
namespace ByJoby\HTML\Nodes;
use ByJoby\HTML\NodeInterface;
use Stringable;
interface TextInterface extends NodeInterface
{
public function __construct(Stringable|string $value);
}

View file

@ -0,0 +1,20 @@
<?php
namespace ByJoby\HTML\Nodes;
use ByJoby\HTML\Traits\NodeTrait;
use Stringable;
class UnsanitizedText implements TextInterface
{
use NodeTrait;
public function __construct(protected Stringable|string $value)
{
}
public function __toString(): string
{
return $this->value;
}
}

View file

@ -1,19 +0,0 @@
<?php
/* HTML Object Strings | https://gitlab.com/byjoby/html-object-strings | MIT License */
namespace HtmlObjectStrings;
interface TagInterface
{
public function attr(string $name, $value = null);
public function data(string $name, $value = null);
public function addClass(string $name);
public function hasClass(string $name) : bool;
public function removeClass(string $name);
public function classes() : array;
public function hidden($hidden=null);
public function string() : string;
public function __toString();
}

View file

@ -1,127 +0,0 @@
<?php
/* HTML Object Strings | https://gitlab.com/byjoby/html-object-strings | MIT License */
namespace HtmlObjectStrings;
trait TagTrait
{
public $tag = 'span';
public $selfClosing = false;
public $content = null;
protected $classes = [];
protected $attributes = [];
protected $hidden = false;
public function hidden($hidden=null)
{
if ($hidden !== null) {
$this->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();
}
}

View file

@ -0,0 +1,20 @@
<?php
namespace ByJoby\HTML\Tags;
use ByJoby\HTML\Helpers\Attributes;
use ByJoby\HTML\Helpers\Classes;
use ByJoby\HTML\Traits\ContainerMutableTrait;
use ByJoby\HTML\Traits\ContainerTagTrait;
use ByJoby\HTML\Traits\ContainerTrait;
use ByJoby\HTML\Traits\TagTrait;
use ByJoby\HTML\Traits\NodeTrait;
abstract class AbstractContainerTag implements ContainerTagInterface
{
use NodeTrait, TagTrait;
use ContainerTrait, ContainerMutableTrait;
use ContainerTagTrait {
ContainerTagTrait::__toString insteadof TagTrait;
}
}

13
src/Tags/AbstractTag.php Normal file
View file

@ -0,0 +1,13 @@
<?php
namespace ByJoby\HTML\Tags;
use ByJoby\HTML\Helpers\Attributes;
use ByJoby\HTML\Helpers\Classes;
use ByJoby\HTML\Traits\TagTrait;
use ByJoby\HTML\Traits\NodeTrait;
abstract class AbstractTag implements TagInterface
{
use NodeTrait, TagTrait;
}

View file

@ -0,0 +1,10 @@
<?php
namespace ByJoby\HTML\Tags;
use ByJoby\HTML\ContainerMutableInterface;
use ByJoby\HTML\NodeCollectionInterface;
interface ContainerTagInterface extends TagInterface, ContainerMutableInterface
{
}

19
src/Tags/TagInterface.php Normal file
View file

@ -0,0 +1,19 @@
<?php
namespace ByJoby\HTML\Tags;
use ByJoby\HTML\Helpers\Attributes;
use ByJoby\HTML\Helpers\Classes;
use ByJoby\HTML\Helpers\Styles;
use ByJoby\HTML\NodeInterface;
use Stringable;
interface TagInterface extends NodeInterface
{
public function tag(): string;
public function id(): null|string;
public function setID(null|string|Stringable $id): static;
public function classes(): Classes;
public function attributes(): Attributes;
public function styles(): Styles;
}

View file

@ -0,0 +1,62 @@
<?php
namespace ByJoby\HTML\Traits;
use ByJoby\HTML\ContainerMutableInterface;
use ByJoby\HTML\NodeInterface;
use ByJoby\HTML\Nodes\Text;
use ByJoby\HTML\Nodes\UnsanitizedText;
use Stringable;
trait ContainerMutableTrait
{
use ContainerTrait;
public function addChild(
NodeInterface|Stringable|string $child,
bool $prepend = false,
bool $skip_sanitize = false
): static {
if (!($child instanceof NodeInterface)) {
if ($skip_sanitize) $child = new UnsanitizedText($child);
else $child = new Text($child);
}
if ($this instanceof NodeInterface) {
$child->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;
}
}

View file

@ -0,0 +1,23 @@
<?php
namespace ByJoby\HTML\Traits;
trait ContainerTagTrait
{
use ContainerTrait;
public function __toString(): string
{
$openingTag = sprintf('<%s>', 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
]
);
}
}

View file

@ -0,0 +1,17 @@
<?php
namespace ByJoby\HTML\Traits;
use ByJoby\HTML\NodeInterface;
trait ContainerTrait
{
/** @var array<int,NodeInterface> */
protected $children = [];
/** @return array<int,NodeInterface> */
public function children(): array
{
return $this->children;
}
}

69
src/Traits/NodeTrait.php Normal file
View file

@ -0,0 +1,69 @@
<?php
namespace ByJoby\HTML\Traits;
use ByJoby\HTML\ContainerInterface;
use ByJoby\HTML\ContainerMutableInterface;
use ByJoby\HTML\Containers\DocumentInterface;
use ByJoby\HTML\NodeInterface;
use DeepCopy\DeepCopy;
use Exception;
trait NodeTrait
{
/** @var null|NodeInterface */
protected $parent;
/** @var null|DocumentInterface */
protected $document;
abstract function __toString();
public function parent(): null|NodeInterface
{
return $this->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);
}
}

101
src/Traits/TagTrait.php Normal file
View file

@ -0,0 +1,101 @@
<?php
namespace ByJoby\HTML\Traits;
use ArrayIterator;
use ByJoby\HTML\ContainerInterface;
use ByJoby\HTML\Helpers\Attributes;
use ByJoby\HTML\Helpers\Classes;
use ByJoby\HTML\Helpers\Styles;
use ByJoby\HTML\NodeInterface;
use Exception;
use Stringable;
trait TagTrait
{
/** @var null|string */
protected $id;
/** @var Attributes */
protected $attributes;
/** @var Classes */
protected $classes;
/** @var Styles */
protected $styles;
abstract function tag(): string;
public function __construct()
{
$this->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<int,string>
*/
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(
['<', '>', '&', '"'],
['&lt;', '&gt;', '&amp;', '&quot;'],
$value
);
}
}

View file

@ -1,79 +0,0 @@
<?php
/* HTML Object Strings | https://gitlab.com/byjoby/html-object-strings | MIT License */
declare(strict_types=1);
namespace HtmlObjectStrings;
use PHPUnit\Framework\TestCase;
class GenericTagTest extends TestCase
{
use \SteveGrunwell\PHPUnit_Markup_Assertions\MarkupAssertionsTrait;
public function testBasicClassManagement()
{
/*
This section tests the basic class adding, removing, and output features
*/
$h = new GenericTag();
//default is no classes
$this->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");
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace ByJoby\HTML\Helpers;
use PHPUnit\Framework\TestCase;
class AttributesTest extends TestCase
{
public function testConstruction(): Attributes
{
$attributes = new Attributes();
$this->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';
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace ByJoby\HTML\Helpers;
use PHPUnit\Framework\TestCase;
class ClassesTest extends TestCase
{
public function testConstruction(): Classes
{
$classes = new Classes();
$this->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');
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace ByJoby\HTML\Helpers;
use PHPUnit\Framework\TestCase;
class StylesTest extends TestCase
{
public function testValidate(): void
{
$this->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']);
}
}

View file

@ -0,0 +1,16 @@
<?php
namespace ByJoby\HTML\Nodes;
use PHPUnit\Framework\TestCase;
class CommentTest extends TestCase
{
public function testSimpleText(): void
{
$this->assertEquals('<!-- -->', new Comment(''));
$this->assertEquals('<!-- foo -->', new Comment('foo'));
$this->assertEquals('<!-- foo-bar -->', new Comment('foo-bar'));
$this->assertNotEquals('<!-- foo--bar -->', new Comment('foo--bar'));
}
}

15
tests/Nodes/TextTest.php Normal file
View file

@ -0,0 +1,15 @@
<?php
namespace ByJoby\HTML\Nodes;
use PHPUnit\Framework\TestCase;
class TextTest extends TestCase
{
public function testSimpleText(): void
{
$this->assertEquals('', new Text(''));
$this->assertEquals('foo', new Text('foo'));
$this->assertEquals('foo', new Text('<strong>foo</strong>'));
}
}

View file

@ -0,0 +1,15 @@
<?php
namespace ByJoby\HTML\Nodes;
use PHPUnit\Framework\TestCase;
class UnsanitizedTextTest extends TestCase
{
public function testSimpleText(): void
{
$this->assertEquals('', new UnsanitizedText(''));
$this->assertEquals('foo', new UnsanitizedText('foo'));
$this->assertEquals('<strong>foo</strong>', new UnsanitizedText('<strong>foo</strong>'));
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace ByJoby\HTML\Tags;
use ByJoby\HTML\Helpers\Attributes;
use ByJoby\HTML\Helpers\Classes;
use PHPUnit\Framework\TestCase;
class AbstractContainerTagTest extends TestCase
{
public function testDIV(): AbstractContainerTag
{
$div = $this->getMockForAbstractClass(AbstractContainerTag::class);
$div->method('tag')->will($this->returnValue('div'));
$this->assertEquals('<div></div>', $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 a="b">',
'<span></span>',
'</div>',
]),
$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 a="b">',
'<span>',
'<span></span>',
'</span>',
'</div>',
]),
$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 a="b"></div>', $div->__toString());
$this->assertNull($span1->parent());
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace ByJoby\HTML\Tags;
use ByJoby\HTML\Helpers\Attributes;
use ByJoby\HTML\Helpers\Classes;
use PHPUnit\Framework\TestCase;
class AbstractTagTest extends TestCase
{
public function testBR(): AbstractTag
{
$br = $this->getMockForAbstractClass(AbstractTag::class);
$br->method('tag')->will($this->returnValue('br'));
$this->assertEquals('<br/>', $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('<br id="foo"/>', $tag->__toString());
$tag->setID(null);
$this->assertNull($tag->id());
$this->assertEquals('<br/>', $tag->__toString());
}
/**
* @depends clone testBR
*/
public function testAttributes(AbstractTag $tag): void
{
$tag->attributes()['b'] = 'c';
$tag->attributes()['a'] = 'b';
$this->assertEquals('<br a="b" b="c"/>', $tag->__toString());
unset($tag->attributes()['a']);
$this->assertEquals('<br b="c"/>', $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';
}
}