diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:30:08 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-13 11:30:08 +0000 |
commit | 4ce65d59ca91871cfd126497158200a818720bce (patch) | |
tree | e277def01fc7eba7dbc21c4a4ae5576e8aa2cf1f /vendor/ipl/html/src | |
parent | Initial commit. (diff) | |
download | icinga-php-library-upstream/0.13.1.tar.xz icinga-php-library-upstream/0.13.1.zip |
Adding upstream version 0.13.1.upstream/0.13.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'vendor/ipl/html/src')
47 files changed, 6660 insertions, 0 deletions
diff --git a/vendor/ipl/html/src/Attribute.php b/vendor/ipl/html/src/Attribute.php new file mode 100644 index 0000000..c42485a --- /dev/null +++ b/vendor/ipl/html/src/Attribute.php @@ -0,0 +1,328 @@ +<?php + +namespace ipl\Html; + +use InvalidArgumentException; + +/** + * HTML Attribute + * + * Every single HTML attribute is (or should be) an instance of this class. + * This guarantees that every attribute is safe and escaped correctly. + * + * Usually attributes are not instantiated directly, but created through an HTML + * element's exposed methods. + */ +class Attribute +{ + /** @var string */ + protected $name; + + /** @var string The separator used if value is an array */ + protected $separator = ' '; + + /** @var string|array|bool|null */ + protected $value; + + /** + * Create a new HTML attribute from the given name and value + * + * @param string $name The name of the attribute + * @param string|bool|array|null $value The value of the attribute + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public function __construct($name, $value = null) + { + $this->setName($name)->setValue($value); + } + + /** + * Create a new HTML attribute from the given name and value + * + * @param string $name The name of the attribute + * @param string|bool|array|null $value The value of the attribute + * + * @return static + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public static function create($name, $value) + { + return new static($name, $value); + } + + /** + * Create a new empty HTML attribute from the given name + * + * The value of the attribute will be null after construction. + * + * @param string $name The name of the attribute + * + * @return static + * + * @throws InvalidArgumentException If the name of the attribute contains special characters + */ + public static function createEmpty($name) + { + return new static($name, null); + } + + /** + * Escape the name of an attribute + * + * Makes sure that the name of an attribute really is a string. + * + * @param string $name + * + * @return string + */ + public static function escapeName($name) + { + return (string) $name; + } + + /** + * Escape the value of an attribute + * + * If the value is an array, returns the string representation + * of all array elements joined with the specified glue string. + * + * Values are escaped according to the HTML5 double-quoted attribute value syntax: + * {@link https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 }. + * + * @param string|array $value + * @param string $glue Glue string to join elements if value is an array + * + * @return string + */ + public static function escapeValue($value, $glue = ' ') + { + if (is_array($value)) { + $value = implode($glue, $value); + } + + // We force double-quoted attribute value syntax so let's start by escaping double quotes + $value = str_replace('"', '"', $value); + + // In addition, values must not contain ambiguous ampersands + $value = preg_replace_callback( + '/&[0-9A-Z]+;/i', + function ($match) { + $subject = $match[0]; + + if (htmlspecialchars_decode($subject, ENT_COMPAT | ENT_HTML5) === $subject) { + // Ambiguous ampersand + return str_replace('&', '&', $subject); + } + + return $subject; + }, + $value + ); + + return $value; + } + + /** + * Get the name of the attribute + * + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * Set the name of the attribute + * + * @param string $name + * + * @return $this + * + * @throws InvalidArgumentException If the name contains special characters + */ + protected function setName($name) + { + if (! preg_match('/^[a-z][a-z0-9:-]*$/i', $name)) { + throw new InvalidArgumentException(sprintf( + 'Attribute names with special characters are not yet allowed: %s', + $name + )); + } + + $this->name = $name; + + return $this; + } + + /** + * Get the separator by which multiple values are concatenated with + * + * @return string + */ + public function getSeparator(): string + { + return $this->separator; + } + + /** + * Set the separator to concatenate multiple values with + * + * @param string $separator + * + * @return $this + */ + public function setSeparator(string $separator): self + { + $this->separator = $separator; + + return $this; + } + + /** + * Get the value of the attribute + * + * @return string|bool|array|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Set the value of the attribute + * + * @param string|bool|array|null $value + * + * @return $this + */ + public function setValue($value) + { + $this->value = $value; + + return $this; + } + + /** + * Add the given value(s) to the attribute + * + * @param string|array $value The value(s) to add + * + * @return $this + */ + public function addValue($value) + { + $this->value = array_merge((array) $this->value, (array) $value); + + return $this; + } + + /** + * Remove the given value(s) from the attribute + * + * The current value is set to null if it matches the value to remove + * or is in the array of values to remove. + * + * If the current value is an array, all elements are removed which + * match the value(s) to remove. + * + * Does nothing if there is no such value to remove. + * + * @param string|array $value The value(s) to remove + * + * @return $this + */ + public function removeValue($value) + { + $value = (array) $value; + + $current = $this->getValue(); + + if (is_array($current)) { + $this->setValue(array_diff($current, $value)); + } elseif (in_array($current, $value, true)) { + $this->setValue(null); + } + + return $this; + } + + /** + * Test and return true if the attribute is boolean, false otherwise + * + * @return bool + */ + public function isBoolean() + { + return is_bool($this->value); + } + + /** + * Test and return true if the attribute is empty, false otherwise + * + * Null and the empty array will be considered empty. + * + * @return bool + */ + public function isEmpty() + { + return $this->value === null || $this->value === []; + } + + /** + * Render the attribute to HTML + * + * If the value of the attribute is of type boolean, it will be rendered as + * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}. + * Note that in this case if the value of the attribute is false, the empty string will be returned. + * + * If the value of the attribute is null or an empty array, + * the empty string will be returned as well. + * + * Escaping of the attribute's value takes place automatically using {@link Attribute::escapeValue()}. + * + * @return string + */ + public function render() + { + if ($this->isEmpty()) { + return ''; + } + + if ($this->isBoolean()) { + if ($this->value) { + return $this->renderName(); + } + + return ''; + } else { + return sprintf( + '%s="%s"', + $this->renderName(), + $this->renderValue() + ); + } + } + + /** + * Render the name of the attribute to HTML + * + * @return string + */ + public function renderName() + { + return static::escapeName($this->name); + } + + /** + * Render the value of the attribute to HTML + * + * @return string + */ + public function renderValue() + { + return static::escapeValue($this->value, $this->separator); + } +} diff --git a/vendor/ipl/html/src/Attributes.php b/vendor/ipl/html/src/Attributes.php new file mode 100644 index 0000000..8df9bbd --- /dev/null +++ b/vendor/ipl/html/src/Attributes.php @@ -0,0 +1,518 @@ +<?php + +namespace ipl\Html; + +use ArrayAccess; +use ArrayIterator; +use InvalidArgumentException; +use IteratorAggregate; +use Traversable; + +use function ipl\Stdlib\get_php_type; + +/** + * HTML attributes + * + * HTML attributes provide additional information about HTML elements, that configure the elements or adjust their + * behavior in various ways. + * + * Attributes usually come in name-value pairs and are rendered as name="value". + */ +class Attributes implements ArrayAccess, IteratorAggregate +{ + /** @var Attribute[] */ + protected $attributes = []; + + /** @var callable[] */ + protected $callbacks = []; + + /** @var string */ + protected $prefix = ''; + + /** @var callable[] */ + protected $setterCallbacks = []; + + /** + * Create new HTML attributes + * + * @param array $attributes + */ + public function __construct(array $attributes = null) + { + if (empty($attributes)) { + return; + } + + foreach ($attributes as $key => $value) { + if ($value instanceof Attribute) { + $this->addAttribute($value); + } elseif (is_string($key)) { + $this->add($key, $value); + } elseif (is_array($value) && count($value) === 2) { + $this->add(array_shift($value), array_shift($value)); + } + } + } + + /** + * Create new HTML attributes + * + * @param array $attributes + * + * @return static + */ + public static function create(array $attributes = null) + { + return new static($attributes); + } + + /** + * Ensure that the given attributes of mixed type are converted to an instance of attributes + * + * The conversion procedure is as follows: + * + * If the given attributes is already an instance of Attributes, returns the very same element. + * If the attributes are given as an array of attribute name-value pairs, they are used to + * construct and return a new Attributes instance. + * If the attributes are null, an empty new instance of Attributes is returned. + * + * @param array|static|null $attributes + * + * @return static + * + * @throws InvalidArgumentException In case the given attributes are of an unsupported type + */ + public static function wantAttributes($attributes) + { + if ($attributes instanceof self) { + return $attributes; + } + + if (is_array($attributes)) { + return new static($attributes); + } + + if ($attributes === null) { + return new static(); + } + + throw new InvalidArgumentException(sprintf( + 'Attributes instance, array or null expected. Got %s instead.', + get_php_type($attributes) + )); + } + + /** + * Get the collection of attributes as array + * + * @return Attribute[] + */ + public function getAttributes() + { + return $this->attributes; + } + + /** + * Merge the given attributes + * + * @param Attributes $attributes + * + * @return $this + */ + public function merge(Attributes $attributes) + { + foreach ($attributes as $attribute) { + $this->addAttribute($attribute); + } + + foreach ($attributes->callbacks as $name => $getter) { + $setter = null; + if (isset($attributes->setterCallbacks[$name])) { + $setter = $attributes->setterCallbacks[$name]; + } + + $this->registerAttributeCallback($name, $getter, $setter); + } + + return $this; + } + + /** + * Return true if the attribute with the given name exists, false otherwise + * + * @param string $name + * + * @return bool + */ + public function has($name) + { + return array_key_exists($name, $this->attributes); + } + + /** + * Get the attribute with the given name + * + * If the attribute does not yet exist, it is automatically created and registered to this Attributes instance. + * + * @param string $name + * + * @return Attribute + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function get($name) + { + if (! $this->has($name)) { + $this->attributes[$name] = Attribute::createEmpty($name); + } + + return $this->attributes[$name]; + } + + /** + * Set the given attribute(s) + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string|array|Attribute|self $attribute The attribute(s) to add + * @param string|bool|array $value The value of the attribute + * + * @return $this + * + * @throws InvalidArgumentException If the attribute name contains special characters + */ + public function set($attribute, $value = null) + { + if ($attribute instanceof self) { + foreach ($attribute as $a) { + $this->setAttribute($a); + } + + return $this; + } + + if ($attribute instanceof Attribute) { + $this->setAttribute($attribute); + + return $this; + } + + if (is_array($attribute)) { + foreach ($attribute as $name => $value) { + $this->set($name, $value); + } + + return $this; + } + + if (array_key_exists($attribute, $this->setterCallbacks)) { + $callback = $this->setterCallbacks[$attribute]; + + $callback($value); + + return $this; + } + + $this->attributes[$attribute] = Attribute::create($attribute, $value); + + return $this; + } + + /** + * Add the given attribute(s) + * + * If an attribute with the same name already exists, the attribute's value will be added to the current value of + * the attribute. + * + * @param string|array|Attribute|self $attribute The attribute(s) to add + * @param string|bool|array $value The value of the attribute + * + * @return $this + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function add($attribute, $value = null) + { + if ($attribute === null) { + return $this; + } + + if ($attribute instanceof self) { + foreach ($attribute as $attr) { + $this->add($attr); + } + + return $this; + } + + if (is_array($attribute)) { + foreach ($attribute as $name => $value) { + $this->add($name, $value); + } + + return $this; + } + + if ($attribute instanceof Attribute) { + $this->addAttribute($attribute); + + return $this; + } + + if (array_key_exists($attribute, $this->setterCallbacks)) { + $callback = $this->setterCallbacks[$attribute]; + + $callback($value); + + return $this; + } + + if (! array_key_exists($attribute, $this->attributes)) { + $this->attributes[$attribute] = Attribute::create($attribute, $value); + } else { + $this->attributes[$attribute]->addValue($value); + } + + return $this; + } + + /** + * Remove the attribute with the given name or remove the given value from the attribute + * + * @param string $name The name of the attribute + * @param null|string|array $value The value to remove if specified + * + * @return ?Attribute The removed or changed attribute, if any, otherwise null + */ + public function remove($name, $value = null): ?Attribute + { + if (! $this->has($name)) { + return null; + } + + $attribute = $this->attributes[$name]; + + if ($value === null) { + unset($this->attributes[$name]); + } else { + $attribute->removeValue($value); + } + + return $attribute; + } + + /** + * Set the specified attribute + * + * @param Attribute $attribute + * + * @return $this + */ + public function setAttribute(Attribute $attribute) + { + $this->attributes[$attribute->getName()] = $attribute; + + return $this; + } + + /** + * Add the specified attribute + * + * If an attribute with the same name already exists, the given attribute's value + * will be added to the current value of the attribute. + * + * @param Attribute $attribute + * + * @return $this + */ + public function addAttribute(Attribute $attribute) + { + $name = $attribute->getName(); + + if ($this->has($name)) { + $this->attributes[$name]->addValue($attribute->getValue()); + } else { + $this->attributes[$name] = $attribute; + } + + return $this; + } + + /** + * Get the attributes name prefix + * + * @return string|null + */ + public function getPrefix() + { + return $this->prefix; + } + + /** + * Set the attributes name prefix + * + * @param string $prefix + * + * @return $this + */ + public function setPrefix($prefix) + { + $this->prefix = $prefix; + + return $this; + } + + /** + * Register callback for an attribute + * + * @param string $name Name of the attribute to register the callback for + * @param ?callable $callback Callback to call when retrieving the attribute + * @param ?callable $setterCallback Callback to call when setting the attribute + * + * @return $this + */ + public function registerAttributeCallback(string $name, ?callable $callback, ?callable $setterCallback = null): self + { + if ($callback !== null) { + $this->callbacks[$name] = $callback; + } + + if ($setterCallback !== null) { + $this->setterCallbacks[$name] = $setterCallback; + } + + return $this; + } + + /** + * Render attributes to HTML + * + * If the value of an attribute is of type boolean, it will be rendered as + * {@link http://www.w3.org/TR/html5/infrastructure.html#boolean-attributes boolean attribute}. + * + * If the value of an attribute is null, it will be skipped. + * + * HTML-escaping of the attributes' values takes place automatically using {@link Attribute::escapeValue()}. + * + * @return string + * + * @throws InvalidArgumentException If the result of a callback is invalid + */ + public function render() + { + $attributes = $this->attributes; + foreach ($this->callbacks as $name => $callback) { + $attribute = call_user_func($callback); + if ($attribute instanceof Attribute) { + if ($attribute->isEmpty()) { + continue; + } + } elseif ($attribute === null) { + continue; + } elseif (is_scalar($attribute)) { + $attribute = Attribute::create($name, $attribute); + } else { + throw new InvalidArgumentException(sprintf( + 'A registered attribute callback must return a scalar, null' + . ' or an Attribute, got a %s', + get_php_type($attribute) + )); + } + + $name = $attribute->getName(); + if (isset($attributes[$name])) { + $attributes[$name] = clone $attributes[$name]; + $attributes[$name]->addValue($attribute->getValue()); + } else { + $attributes[$name] = $attribute; + } + } + + $parts = []; + foreach ($attributes as $attribute) { + if ($attribute->isEmpty()) { + continue; + } + + $parts[] = $attribute->render(); + } + + if (empty($parts)) { + return ''; + } + + $separator = ' ' . $this->getPrefix(); + + return $separator . implode($separator, $parts); + } + + /** + * Get whether the attribute with the given name exists + * + * @param string $name Name of the attribute + * + * @return bool + */ + public function offsetExists($name): bool + { + return $this->has($name); + } + + /** + * Get the attribute with the given name + * + * If the attribute does not yet exist, it is automatically created and registered to this Attributes instance. + * + * @param string $name Name of the attribute + * + * @return Attribute + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function offsetGet($name): Attribute + { + return $this->get($name); + } + + /** + * Set the given attribute + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string $name Name of the attribute + * @param mixed $value Value of the attribute + * + * @throws InvalidArgumentException If the attribute name contains special characters + */ + public function offsetSet($name, $value): void + { + $this->set($name, $value); + } + + /** + * Remove the attribute with the given name + * + * @param string $name Name of the attribute + */ + public function offsetUnset($name): void + { + $this->remove($name); + } + + /** + * Get an iterator for traversing the attributes + * + * @return Attribute[]|ArrayIterator + */ + public function getIterator(): Traversable + { + return new ArrayIterator($this->attributes); + } + + public function __clone() + { + foreach ($this->attributes as &$attribute) { + $attribute = clone $attribute; + } + } +} diff --git a/vendor/ipl/html/src/BaseHtmlElement.php b/vendor/ipl/html/src/BaseHtmlElement.php new file mode 100644 index 0000000..5dc01ce --- /dev/null +++ b/vendor/ipl/html/src/BaseHtmlElement.php @@ -0,0 +1,406 @@ +<?php + +namespace ipl\Html; + +use InvalidArgumentException; +use RuntimeException; + +/** + * Base class for HTML elements + * + * Extend this class in order to provide concrete HTML elements or series of HTML elements, e.g. widgets. + * When extending this class you should provide the element's tag with {@link $tag}. Setting default attributes is + * possible via {@link $defaultAttributes}. And the content of the element is provided in {@link assemble()}. + * + * # Example Usage + * ``` + * namespace Acme\Widgets; + * + * use ipl\Html\BaseHtmlElement; + * + * class Dashboard extends BaseHtmlElement + * { + * protected $defaultAttributes = ['class' => 'acme-dashboard']; + * + * protected $tag = 'div'; + * + * protected function assemble() + * { + * // ... + * $this->add($content); + * } + * } + * ``` + */ +abstract class BaseHtmlElement extends HtmlDocument +{ + /** + * List of void elements which must not contain end tags or content + * + * If {@link $isVoid} is null, this property should be used to decide whether the content and end tag has to be + * rendered. + * + * @var array + * + * @see https://www.w3.org/TR/html5/syntax.html#void-elements + */ + protected static $voidElements = [ + 'area' => 1, + 'base' => 1, + 'br' => 1, + 'col' => 1, + 'embed' => 1, + 'hr' => 1, + 'img' => 1, + 'input' => 1, + 'link' => 1, + 'meta' => 1, + 'param' => 1, + 'source' => 1, + 'track' => 1, + 'wbr' => 1 + ]; + + /** @var Attributes */ + protected $attributes; + + /** @var bool Whether possible attribute callbacks have been registered */ + protected $attributeCallbacksRegistered = false; + + /** @var bool|null Whether the element is void. If null, void check should use {@link $voidElements} */ + protected $isVoid; + + /** @var array<string, mixed> You may want to set default attributes when extending this class */ + protected $defaultAttributes; + + /** @var string Tag of element. Set this property in order to provide the element's tag when extending this class */ + protected $tag; + + /** + * Get the attributes of the element + * + * @return Attributes + */ + public function getAttributes() + { + if ($this->attributes === null) { + $default = $this->getDefaultAttributes(); + if (empty($default)) { + $this->attributes = new Attributes(); + } else { + $this->attributes = Attributes::wantAttributes($default); + } + + $this->ensureAttributeCallbacksRegistered(); + } + + return $this->attributes; + } + + /** + * Set the attributes of the element + * + * @param Attributes|array|null $attributes + * + * @return $this + */ + public function setAttributes($attributes) + { + $this->attributes = Attributes::wantAttributes($attributes); + + $this->attributeCallbacksRegistered = false; + $this->ensureAttributeCallbacksRegistered(); + + return $this; + } + + /** + * Return true if the attribute with the given name exists, false otherwise + * + * @param string $name + * + * @return bool + */ + public function hasAttribute(string $name): bool + { + return $this->getAttributes()->has($name); + } + + /** + * Get the attribute with the given name + * + * If the attribute does not already exist, an empty one is automatically created and added to the attributes. + * + * @param string $name + * + * @return Attribute + * + * @throws InvalidArgumentException If the attribute does not yet exist and its name contains special characters + */ + public function getAttribute(string $name): Attribute + { + return $this->getAttributes()->get($name); + } + + /** + * Set the attribute with the given name and value + * + * If the attribute with the given name already exists, it gets overridden. + * + * @param string $name The name of the attribute + * @param string|bool|array $value The value of the attribute + * + * @return $this + */ + public function setAttribute($name, $value) + { + $this->getAttributes()->set($name, $value); + + return $this; + } + + /** + * Remove the attribute with the given name or remove the given value from the attribute + * + * @param string $name The name of the attribute + * @param null|string|array $value The value to remove if specified + * + * @return ?Attribute The removed or changed attribute, if any, otherwise null + */ + public function removeAttribute(string $name, $value = null): ?Attribute + { + return $this->getAttributes()->remove($name, $value); + } + + /** + * Add the given attributes + * + * @param Attributes|array $attributes + * + * @return $this + */ + public function addAttributes($attributes) + { + $this->getAttributes()->add($attributes); + + return $this; + } + + /** + * Get the default attributes of the element + * + * @return array + */ + public function getDefaultAttributes() + { + return $this->defaultAttributes; + } + + /** + * Get the tag of the element + * + * Since HTML Elements must have a tag, this method throws an exception if the element does not have a tag. + * + * @return string + * + * @throws RuntimeException If the element does not have a tag + */ + final public function getTag() + { + $tag = $this->tag(); + + if (! $tag) { + throw new RuntimeException('Element must have a tag'); + } + + return $tag; + } + + /** + * Set the tag of the element + * + * @param string $tag + * + * @return $this + */ + public function setTag($tag) + { + $this->tag = $tag; + + return $this; + } + + /** + * Get whether the element is void + * + * The default void detection which checks whether the element's tag is in the list of void elements according to + * https://www.w3.org/TR/html5/syntax.html#void-elements. + * + * If you want to override this behavior, use {@link setVoid()}. + * + * @return bool + */ + public function isVoid() + { + if ($this->isVoid !== null) { + return $this->isVoid; + } + + $tag = $this->getTag(); + + return isset(self::$voidElements[$tag]); + } + + /** + * Set whether the element is void + * + * You may use this method to override the default void detection which checks whether the element's tag is in the + * list of void elements according to https://www.w3.org/TR/html5/syntax.html#void-elements. + * + * If you specify null, void detection is reset to its default behavior. + * + * @param bool|null $void + * + * @return $this + */ + public function setVoid($void = true) + { + $this->isVoid = $void === null ?: (bool) $void; + + return $this; + } + + /** + * Ensure that possible attribute callbacks have been registered + * + * Note that this method is automatically called in {@link getAttributes()} and {@link setAttributes()}. + * + * @return $this + */ + public function ensureAttributeCallbacksRegistered() + { + if (! $this->attributeCallbacksRegistered) { + $this->attributeCallbacksRegistered = true; + $this->registerAttributeCallbacks($this->attributes); + } + + return $this; + } + + /** + * Render the content of the element to HTML + * + * @return string + */ + public function renderContent() + { + return parent::renderUnwrapped(); + } + + /** + * Get whether the closing tag should be rendered + * + * @return bool True for void elements, false otherwise + */ + public function wantsClosingTag() + { + // TODO: There is more. SVG and MathML namespaces + return ! $this->isVoid(); + } + + /** + * Use this element to wrap the given document + * + * @param HtmlDocument $document + * + * @return $this + */ + public function wrap(HtmlDocument $document) + { + $document->addWrapper($this); + + return $this; + } + + /** + * Internal method for accessing the tag + * + * You may override this method in order to provide the tag dynamically + * + * @return string + */ + protected function tag() + { + return $this->tag; + } + + /** + * Register attribute callbacks + * + * Override this method in order to register attribute callbacks in concrete classes. + */ + protected function registerAttributeCallbacks(Attributes $attributes) + { + } + + public function addHtml(ValidHtml ...$content) + { + $this->ensureAssembled(); + + parent::addHtml(...$content); + + return $this; + } + + /** + * @inheritdoc + * + * @throws RuntimeException If the element does not have a tag or is void but has content + */ + public function renderUnwrapped() + { + $this->ensureAssembled(); + + $tag = $this->getTag(); + $content = $this->renderContent(); + $attributes = $this->getAttributes()->render(); + + if (strlen($this->contentSeparator)) { + $length = strlen($content); + if ($length > 0) { + if ($content[0] === '<') { + $content = $this->contentSeparator . $content; + $length++; + } + if ($content[$length - 1] === '>') { + $content .= $this->contentSeparator; + } + } + } + + if (! $this->wantsClosingTag()) { + if (strlen($content)) { + throw new RuntimeException('Void elements must not have content'); + } + + return sprintf('<%s%s />', $tag, $attributes); + } + + return sprintf( + '<%s%s>%s</%s>', + $tag, + $attributes, + $content, + $tag + ); + } + + public function __clone() + { + parent::__clone(); + + if ($this->attributes !== null) { + $this->attributes = clone $this->attributes; + } + } +} diff --git a/vendor/ipl/html/src/Common/MultipleAttribute.php b/vendor/ipl/html/src/Common/MultipleAttribute.php new file mode 100644 index 0000000..00a68b2 --- /dev/null +++ b/vendor/ipl/html/src/Common/MultipleAttribute.php @@ -0,0 +1,70 @@ +<?php + +namespace ipl\Html\Common; + +use ipl\Html\Attributes; +use ipl\Html\Contract\FormElement; + +/** + * Trait for form elements that can have the `multiple` attribute + * + * **Example usage:** + * + * ``` + * namespace ipl\Html\FormElement; + * + * use ipl\Html\Common\MultipleAttribute; + * + * class SelectElement extends BaseFormElement + * { + * protected function registerAttributeCallbacks(Attributes $attributes) + * { + * // ... + * $this->registerMultipleAttributeCallback($attributes); + * } + * } + * ``` + */ +trait MultipleAttribute +{ + /** @var bool Whether the attribute `multiple` is set to `true` */ + protected $multiple = false; + + /** + * Get whether the attribute `multiple` is set to `true` + * + * @return bool + */ + public function isMultiple(): bool + { + return $this->multiple; + } + + /** + * Set the `multiple` attribute + * + * @param bool $multiple + * + * @return $this + */ + public function setMultiple(bool $multiple): self + { + $this->multiple = $multiple; + + return $this; + } + + /** + * Register the callback for `multiple` Attribute + * + * @param Attributes $attributes + */ + protected function registerMultipleAttributeCallback(Attributes $attributes): void + { + $attributes->registerAttributeCallback( + 'multiple', + [$this, 'isMultiple'], + [$this, 'setMultiple'] + ); + } +} diff --git a/vendor/ipl/html/src/Contract/FormElement.php b/vendor/ipl/html/src/Contract/FormElement.php new file mode 100644 index 0000000..1467c50 --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormElement.php @@ -0,0 +1,132 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\Attributes; +use ipl\Html\Form; + +/** + * Representation of form elements + */ +interface FormElement extends Wrappable +{ + /** + * Get the attributes or options of the element + * + * @return Attributes + */ + public function getAttributes(); + + /** + * Add attributes or options to the form element + * + * @param iterable $attributes + * + * @return $this + */ + public function addAttributes($attributes); + + /** + * Get the description for the element, if any + * + * @return string|null + */ + public function getDescription(); + + /** + * Get the label for the element, if any + * + * @return string|null + */ + public function getLabel(); + + /** + * Get the validation error messages + * + * @return array + */ + public function getMessages(); + + /** + * Add a validation error message + * + * @param string $message + * + * @return $this + */ + public function addMessage($message); + + /** + * Get the name of the element + * + * @return string + */ + public function getName(); + + /** + * Get whether the element has a value + * + * @return bool False if the element's value is null, the empty string or the empty array; true otherwise + */ + public function hasValue(); + + /** + * Get the value of the element + * + * @return mixed + */ + public function getValue(); + + /** + * Set the value of the element + * + * @param mixed $value + * + * @return $this + */ + public function setValue($value); + + /** + * Get whether the element has been validated + * + * @return bool + */ + public function hasBeenValidated(); + + /** + * Get whether the element is ignored + * + * @return bool + */ + public function isIgnored(); + + /** + * Get whether the element is required + * + * @return bool + */ + public function isRequired(); + + /** + * Get whether the element is valid + * + * @return bool + */ + public function isValid(); + + /** + * Validate the element + * + * @return $this + */ + public function validate(); + + /** + * Handler which is called after this element has been registered + * + * @param Form $form + * + * @return void + */ + public function onRegistered(Form $form); +} diff --git a/vendor/ipl/html/src/Contract/FormElementDecorator.php b/vendor/ipl/html/src/Contract/FormElementDecorator.php new file mode 100644 index 0000000..ca770ea --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormElementDecorator.php @@ -0,0 +1,40 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\ValidHtml; + +/** + * Representation of form element decorators + */ +interface FormElementDecorator extends ValidHtml +{ + /** + * Decorate the given form element + * + * Decoration works by calling `prependWrapper()` on the form element, + * passing a clone of the decorator. Hidden elements are to be ignored. + * + * **Reference implementation:** + * + * ```php + * public function decorate(FormElement $formElement) + * { + * if ($formElement instanceof HiddenElement) { + * return; + * } + * + * $decorator = clone $this; + * + * // Wrapper logic can be overridden to adjust or propagate the decorator. + * // So here we make sure that a yet unbound decorator is passed. + * $formElement->prependWrapper($decorator); + * + * ... + * } + * ``` + * + * @param FormElement $formElement + */ + public function decorate(FormElement $formElement); +} diff --git a/vendor/ipl/html/src/Contract/FormSubmitElement.php b/vendor/ipl/html/src/Contract/FormSubmitElement.php new file mode 100644 index 0000000..4909df8 --- /dev/null +++ b/vendor/ipl/html/src/Contract/FormSubmitElement.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Html\Contract; + +interface FormSubmitElement extends FormElement +{ + /** + * Get whether the element has been pressed + * + * @return bool + */ + public function hasBeenPressed(); +} diff --git a/vendor/ipl/html/src/Contract/ValueCandidates.php b/vendor/ipl/html/src/Contract/ValueCandidates.php new file mode 100644 index 0000000..1cd2d6f --- /dev/null +++ b/vendor/ipl/html/src/Contract/ValueCandidates.php @@ -0,0 +1,22 @@ +<?php + +namespace ipl\Html\Contract; + +interface ValueCandidates +{ + /** + * Get value candidates of this element + * + * @return array<int, mixed> + */ + public function getValueCandidates(); + + /** + * Set value candidates of this element + * + * @param array<int, mixed> $values + * + * @return $this + */ + public function setValueCandidates(array $values); +} diff --git a/vendor/ipl/html/src/Contract/Wrappable.php b/vendor/ipl/html/src/Contract/Wrappable.php new file mode 100644 index 0000000..c8cf924 --- /dev/null +++ b/vendor/ipl/html/src/Contract/Wrappable.php @@ -0,0 +1,45 @@ +<?php + +namespace ipl\Html\Contract; + +use ipl\Html\ValidHtml; + +/** + * Representation of wrappable elements + */ +interface Wrappable extends ValidHtml +{ + /** + * Get the wrapper, if any + * + * @return Wrappable|null + */ + public function getWrapper(); + + /** + * Set the wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function setWrapper(Wrappable $wrapper); + + /** + * Add a wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function addWrapper(Wrappable $wrapper); + + /** + * Prepend a wrapper + * + * @param Wrappable $wrapper + * + * @return $this + */ + public function prependWrapper(Wrappable $wrapper); +} diff --git a/vendor/ipl/html/src/DeferredText.php b/vendor/ipl/html/src/DeferredText.php new file mode 100644 index 0000000..2d308f1 --- /dev/null +++ b/vendor/ipl/html/src/DeferredText.php @@ -0,0 +1,114 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * Text node where content creation is deferred until rendering + * + * The content is created by a passed in callback which is only called when the node is going to be rendered and + * automatically escaped to HTML. + * If the created content is already escaped, see {@link setEscaped()} to indicate this. + * + * # Example Usage + * ``` + * $benchmark = new Benchmark(); + * + * $performance = new DeferredText(function () use ($benchmark) { + * return $benchmark->summary(); + * }); + * + * execute_query(); + * + * $benchmark->tick('Fetched results'); + * + * generate_report(); + * + * $benchmark->tick('Report generated'); + * + * echo $performance; + * ``` + */ +class DeferredText implements ValidHtml +{ + /** @var callable will return the text that should be rendered */ + protected $callback; + + /** @var bool */ + protected $escaped = false; + + /** + * Create a new text node where content creation is deferred until rendering + * + * @param callable $callback Must return the content that should be rendered + */ + public function __construct(callable $callback) + { + $this->callback = $callback; + } + + /** + * Create a new text node where content creation is deferred until rendering + * + * @param callable $callback Must return the content that should be rendered + * + * @return static + */ + public static function create(callable $callback) + { + return new static($callback); + } + + /** + * Get whether the callback promises that its content is already escaped + * + * @return bool + */ + public function isEscaped() + { + return $this->escaped; + } + + /** + * Set whether the callback's content is already escaped + * + * @param bool $escaped + * + * @return $this + */ + public function setEscaped($escaped = true) + { + $this->escaped = $escaped; + + return $this; + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + $callback = $this->callback; + + if ($this->escaped) { + return $callback(); + } else { + return Html::escape($callback()); + } + } +} diff --git a/vendor/ipl/html/src/Error.php b/vendor/ipl/html/src/Error.php new file mode 100644 index 0000000..316d237 --- /dev/null +++ b/vendor/ipl/html/src/Error.php @@ -0,0 +1,114 @@ +<?php + +namespace ipl\Html; + +use Exception; +use Throwable; + +use function ipl\Stdlib\get_php_type; + +/** + * Class Error + * + * TODO: Eventually allow to (statically) inject a custom error renderer. + * + * @package ipl\Html + */ +abstract class Error +{ + /** @var bool */ + protected static $showTraces = true; + + /** + * + * @param Exception|Throwable|string $error + * @return HtmlDocument + */ + public static function show($error) + { + if ($error instanceof Throwable) { + // PHP 7+ + $msg = static::createMessageForException($error); + } elseif (is_string($error)) { + $msg = $error; + } else { + // TODO: translate? + $msg = 'Got an invalid error'; + } + + $result = static::renderErrorMessage($msg); + if (static::showTraces() && $error instanceof Throwable) { + $result->addHtml(Html::tag('pre', $error->getTraceAsString())); + } + + return $result; + } + + /** + * + * @param Exception|Throwable|string $error + * @return string + */ + public static function render($error) + { + return static::show($error)->render(); + } + + /** + * @param bool|null $show + * @return bool + */ + public static function showTraces($show = null) + { + if ($show !== null) { + self::$showTraces = (bool) $show; + } + + return self::$showTraces; + } + + /** + * @deprecated Use {@link get_php_type()} instead + */ + public static function getPhpTypeName($any) + { + return get_php_type($any); + } + + /** + * @param Exception|Throwable $exception + * @return string + */ + protected static function createMessageForException($exception) + { + $file = preg_split('/[\/\\\]/', $exception->getFile(), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $file = array_pop($file); + return sprintf( + '%s (%s:%d)', + $exception->getMessage(), + $file, + $exception->getLine() + ); + } + + /** + * @param string + * @return HtmlDocument + */ + protected static function renderErrorMessage($message) + { + $output = new HtmlDocument(); + $output->addHtml( + Html::tag('div', ['class' => 'exception'], [ + Html::tag('h1', [ + Html::tag('i', ['class' => 'icon-bug']), + // TODO: Translate? More hints? + 'Oops, an error occurred!' + ]), + Html::tag('pre', $message) + ]) + ); + + return $output; + } +} diff --git a/vendor/ipl/html/src/Form.php b/vendor/ipl/html/src/Form.php new file mode 100644 index 0000000..a7360c7 --- /dev/null +++ b/vendor/ipl/html/src/Form.php @@ -0,0 +1,402 @@ +<?php + +namespace ipl\Html; + +use Exception; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormElement\FormElements; +use ipl\Stdlib\Messages; +use Psr\Http\Message\ServerRequestInterface; + +class Form extends BaseHtmlElement +{ + use FormElements { + FormElements::remove as private removeElement; + } + use Messages; + + public const ON_ELEMENT_REGISTERED = 'elementRegistered'; + public const ON_ERROR = 'error'; + public const ON_REQUEST = 'request'; + public const ON_SUCCESS = 'success'; + public const ON_SENT = 'sent'; + public const ON_VALIDATE = 'validate'; + + /** @var string Form submission URL */ + protected $action; + + /** @var string HTTP method to submit the form with */ + protected $method = 'POST'; + + /** @var FormSubmitElement Primary submit button */ + protected $submitButton; + + /** @var FormSubmitElement[] Other elements that may submit the form */ + protected $submitElements = []; + + /** @var bool Whether the form is valid */ + protected $isValid; + + /** @var ServerRequestInterface The server request being processed */ + protected $request; + + /** @var string */ + protected $redirectUrl; + + protected $tag = 'form'; + + /** + * Get whether the given value is empty + * + * @param mixed $value + * + * @return bool + */ + public static function isEmptyValue($value): bool + { + return $value === null || $value === '' || $value === []; + } + + /** + * Get the Form submission URL + * + * @return string|null + */ + public function getAction() + { + return $this->action; + } + + /** + * Set the Form submission URL + * + * @param string $action + * + * @return $this + */ + public function setAction($action) + { + $this->action = $action; + + return $this; + } + + /** + * Get the HTTP method to submit the form with + * + * @return string + */ + public function getMethod() + { + return $this->method; + } + + /** + * Set the HTTP method to submit the form with + * + * @param string $method + * + * @return $this + */ + public function setMethod($method) + { + $this->method = strtoupper($method); + + return $this; + } + + /** + * Get whether the form has a primary submit button + * + * @return bool + */ + public function hasSubmitButton() + { + return $this->submitButton !== null; + } + + /** + * Get the primary submit button + * + * @return FormSubmitElement|null + */ + public function getSubmitButton() + { + return $this->submitButton; + } + + /** + * Set the primary submit button + * + * @param FormSubmitElement $element + * + * @return $this + */ + public function setSubmitButton(FormSubmitElement $element) + { + $this->submitButton = $element; + + return $this; + } + + /** + * Get the submit element used to send the form + * + * @return FormSubmitElement|null + */ + public function getPressedSubmitElement() + { + foreach ($this->submitElements as $submitElement) { + if ($submitElement->hasBeenPressed()) { + return $submitElement; + } + } + + return null; + } + + /** + * @return ServerRequestInterface|null + */ + public function getRequest() + { + return $this->request; + } + + public function setRequest($request) + { + $this->request = $request; + $this->emit(Form::ON_REQUEST, [$request]); + + return $this; + } + + /** + * Get the url to redirect to on success + * + * @return string + */ + public function getRedirectUrl() + { + return $this->redirectUrl; + } + + /** + * Set the url to redirect to on success + * + * @param string $url + * + * @return $this + */ + public function setRedirectUrl($url) + { + $this->redirectUrl = $url; + + return $this; + } + + /** + * @param ServerRequestInterface $request + * + * @return $this + */ + public function handleRequest(ServerRequestInterface $request) + { + $this->setRequest($request); + + if (! $this->hasBeenSent()) { + // Always assemble + $this->ensureAssembled(); + + return $this; + } + + switch ($request->getMethod()) { + case 'POST': + $params = $request->getParsedBody(); + + break; + case 'GET': + parse_str($request->getUri()->getQuery(), $params); + + break; + default: + $params = []; + } + + $params = array_merge_recursive($params, $request->getUploadedFiles()); + $this->populate($params); + + // Assemble after populate in order to conditionally provide form elements + $this->ensureAssembled(); + + if ($this->hasBeenSubmitted()) { + if ($this->isValid()) { + try { + $this->emit(Form::ON_SENT, [$this]); + $this->onSuccess(); + $this->emitOnce(Form::ON_SUCCESS, [$this]); + } catch (Exception $e) { + $this->addMessage($e); + $this->onError(); + $this->emit(Form::ON_ERROR, [$e, $this]); + } + } else { + $this->onError(); + } + } else { + $this->validatePartial(); + $this->emit(Form::ON_SENT, [$this]); + } + + return $this; + } + + /** + * Get whether the form has been sent + * + * A form is considered sent if the request's method equals the form's method. + * + * @return bool + */ + public function hasBeenSent() + { + if ($this->request === null) { + return false; + } + + return $this->request->getMethod() === $this->getMethod(); + } + + /** + * Get whether the form has been submitted + * + * A form is submitted when it has been sent and when the primary submit button, if set, has been pressed. + * This method calls {@link hasBeenSent()} in order to detect whether the form has been sent. + * + * @return bool + */ + public function hasBeenSubmitted() + { + if (! $this->hasBeenSent()) { + return false; + } + + if ($this->hasSubmitButton()) { + return $this->getSubmitButton()->hasBeenPressed(); + } + + return true; + } + + /** + * Get whether the form is valid + * + * {@link validate()} is called automatically if the form has not been validated before. + * + * @return bool + */ + public function isValid() + { + if ($this->isValid === null) { + $this->validate(); + + $this->emit(self::ON_VALIDATE, [$this]); + } + + return $this->isValid; + } + + /** + * Validate all elements + * + * @return $this + */ + public function validate() + { + $this->ensureAssembled(); + + $valid = true; + foreach ($this->getElements() as $element) { + $element->validate(); + if (! $element->isValid()) { + $valid = false; + } + } + + $this->isValid = $valid; + + return $this; + } + + /** + * Validate all elements that have a value + * + * @return $this + */ + public function validatePartial() + { + $this->ensureAssembled(); + + foreach ($this->getElements() as $element) { + if ($element->hasValue()) { + $element->validate(); + } + } + + return $this; + } + + public function remove(ValidHtml $elementOrHtml) + { + if ($this->submitButton === $elementOrHtml) { + $this->submitButton = null; + } + + $this->removeElement($elementOrHtml); + + return $this; + } + + protected function onError() + { + $errors = Html::tag('ul', ['class' => 'errors']); + foreach ($this->getMessages() as $message) { + if ($message instanceof Exception) { + $message = $message->getMessage(); + } + + $errors->addHtml(Html::tag('li', $message)); + } + + if (! $errors->isEmpty()) { + $this->prependHtml($errors); + } + } + + protected function onSuccess() + { + // $this->redirectOnSuccess(); + } + + protected function onElementRegistered(FormElement $element) + { + if ($element instanceof FormSubmitElement) { + $this->submitElements[$element->getName()] = $element; + + if (! $this->hasSubmitButton()) { + $this->setSubmitButton($element); + } + } + + $element->onRegistered($this); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + $attributes + ->registerAttributeCallback('action', [$this, 'getAction'], [$this, 'setAction']) + ->registerAttributeCallback('method', [$this, 'getMethod'], [$this, 'setMethod']); + } +} diff --git a/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php b/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php new file mode 100644 index 0000000..1048bfb --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/CallbackDecorator.php @@ -0,0 +1,41 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use Closure; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\HtmlDocument; + +class CallbackDecorator extends HtmlDocument implements FormElementDecorator +{ + /** @var Closure The decorating callback */ + protected $callback; + + /** @var FormElement The decorated form element */ + protected $formElement; + + /** + * Create a new CallbackDecorator + * + * @param Closure $callback + */ + public function __construct(Closure $callback) + { + $this->callback = $callback; + } + + public function decorate(FormElement $formElement) + { + $decorator = clone $this; + + $decorator->formElement = $formElement; + + $formElement->prependWrapper($decorator); + } + + protected function assemble() + { + call_user_func($this->callback, $this->formElement, $this); + } +} diff --git a/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php b/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php new file mode 100644 index 0000000..9cfec20 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DdDtDecorator.php @@ -0,0 +1,140 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\BaseHtmlElement; +use ipl\Html\FormElement\BaseFormElement; +use ipl\Html\Html; +use ipl\Html\ValidHtml; + +class DdDtDecorator extends BaseHtmlElement implements DecoratorInterface +{ + protected $tag = 'dl'; + + protected $dt; + + protected $dd; + + /** @var BaseFormElement */ + protected $wrappedElement; + + protected $ready = false; + + /** + * @param BaseFormElement $element + * @return static + */ + public function decorate(BaseFormElement $element) + { + // TODO: ignore hidden? + $newWrapper = clone($this); + $newWrapper->wrappedElement = $element; + $element->prependWrapper($newWrapper); + + return $newWrapper; + } + + protected function renderLabel() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $label = $this->wrappedElement->getLabel(); + if ($label) { + return Html::tag('label', null, $label); + } + } + + return null; + } + + public function getAttributes() + { + $attributes = parent::getAttributes(); + + if ($this->wrappedElement->hasBeenValidated() && ! $this->wrappedElement->isValid()) { + $classes = $attributes->get('class')->getValue(); + if ( + empty($classes) + || (is_array($classes) && ! in_array('errors', $classes)) + || (is_string($classes) && $classes !== 'errors') + ) { + $attributes->add('class', 'errors'); + } + } + + return $attributes; + } + + protected function renderDescription() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $description = $this->wrappedElement->getDescription(); + if ($description) { + return Html::tag('p', ['class' => 'description'], $description); + } + } + + return null; + } + + protected function renderErrors() + { + if ($this->wrappedElement instanceof BaseFormElement) { + $errors = []; + foreach ($this->wrappedElement->getMessages() as $message) { + $errors[] = Html::tag('p', ['class' => 'error'], $message); + } + + if (! empty($errors)) { + return $errors; + } + } + + return null; + } + + public function addHtml(ValidHtml ...$content) + { + // TODO: is this required? + if (! in_array($this->wrappedElement, $content, true)) { + parent::addHtml(...$content); + } + + return $this; + } + + protected function assemble() + { + $this->addHtml($this->dt(), $this->dd()); + $this->ready = true; + } + + protected function dt() + { + if ($this->dt === null) { + $this->dt = Html::tag('dt', null, $this->renderLabel()); + } + + return $this->dt; + } + + /** + * @return \ipl\Html\HtmlElement + */ + protected function dd() + { + if ($this->dd === null) { + $this->dd = Html::tag('dd', null, [ + $this->wrappedElement, + $this->renderErrors(), + $this->renderDescription() + ]); + } + + return $this->dd; + } + + public function __destruct() + { + $this->wrapper = null; + } +} diff --git a/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php b/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php new file mode 100644 index 0000000..1ed1a38 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DecoratorInterface.php @@ -0,0 +1,19 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\FormElement\BaseFormElement; + +/** @deprecated Use {@link FormElementDecorator} instead */ +interface DecoratorInterface +{ + /** + * Set the form element to decorate + * + * @param BaseFormElement $formElement + * + * @return static + */ + public function decorate(BaseFormElement $formElement); +} diff --git a/vendor/ipl/html/src/FormDecorator/DivDecorator.php b/vendor/ipl/html/src/FormDecorator/DivDecorator.php new file mode 100644 index 0000000..4935f01 --- /dev/null +++ b/vendor/ipl/html/src/FormDecorator/DivDecorator.php @@ -0,0 +1,156 @@ +<?php + +namespace ipl\Html\FormDecorator; + +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\Contract\FormSubmitElement; +use ipl\Html\FormElement\FieldsetElement; +use ipl\Html\FormElement\HiddenElement; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Html\Text; + +/** + * Form element decorator based on div elements + */ +class DivDecorator extends BaseHtmlElement implements FormElementDecorator +{ + /** @var string CSS class to use for submit elements */ + public const SUBMIT_ELEMENT_CLASS = 'form-control'; + + /** @var string CSS class to use for all input elements */ + public const INPUT_ELEMENT_CLASS = 'form-element'; + + /** @var string CSS class to use for form descriptions */ + public const DESCRIPTION_CLASS = 'form-element-description'; + + /** @var string CSS class to use for form errors */ + public const ERROR_CLASS = 'form-element-errors'; + + /** @var string CSS class to set on the decorator if the element has errors */ + public const ERROR_HINT_CLASS = 'has-error'; + + /** @var FormElement The decorated form element */ + protected $formElement; + + protected $tag = 'div'; + + public function decorate(FormElement $formElement) + { + if ($formElement instanceof HiddenElement) { + return; + } + + $decorator = clone $this; + + /** + * Wrapper logic can be overridden to propagate the decorator. + * So here we make sure that a yet unbound decorator is passed. + * + * {@see FieldsetElement::setWrapper()} + */ + $formElement->prependWrapper($decorator); + + $decorator->formElement = $formElement; + + $classes = [static::INPUT_ELEMENT_CLASS]; + if ($formElement instanceof FormSubmitElement) { + $classes[] = static::SUBMIT_ELEMENT_CLASS; + } + + $decorator->getAttributes()->add('class', $classes); + } + + protected function assembleDescription() + { + $description = $this->formElement->getDescription(); + + if ($description !== null) { + $descriptionId = null; + if ($this->formElement->getAttributes()->has('id')) { + $descriptionId = 'desc_' . $this->formElement->getAttributes()->get('id')->getValue(); + $this->formElement->getAttributes()->set('aria-describedby', $descriptionId); + } + + return Html::tag('p', ['id' => $descriptionId, 'class' => static::DESCRIPTION_CLASS], $description); + } + + return null; + } + + protected function assembleElement() + { + if ($this->formElement->isRequired()) { + $this->formElement->getAttributes()->set('aria-required', 'true'); + } + + return $this->formElement->ensureAssembled(); + } + + protected function assembleErrors() + { + $errors = new HtmlElement('ul', Attributes::create(['class' => static::ERROR_CLASS])); + + foreach ($this->formElement->getMessages() as $message) { + $errors->addHtml( + new HtmlElement('li', Attributes::create(['class' => static::ERROR_CLASS]), Text::create($message)) + ); + } + + if (! $errors->isEmpty()) { + return $errors; + } + + return null; + } + + protected function assembleLabel() + { + $label = $this->formElement->getLabel(); + + if ($label !== null) { + if ($this->formElement instanceof FieldsetElement) { + return new HtmlElement('legend', null, Text::create($label)); + } else { + $attributes = null; + if ($this->formElement->getAttributes()->has('id')) { + $attributes = new Attributes(['for' => $this->formElement->getAttributes()->get('id')->getValue()]); + } + + return Html::tag('label', $attributes, $label); + } + } + + return null; + } + + protected function assemble() + { + if ($this->formElement->hasBeenValidated() && ! $this->formElement->isValid()) { + $this->getAttributes()->add('class', static::ERROR_HINT_CLASS); + } + + if ($this->formElement instanceof FieldsetElement) { + $element = $this->assembleElement(); + $element->prependHtml(...Html::wantHtmlList([ + $this->assembleLabel(), + $this->assembleDescription() + ])); + + $this->addHtml(...Html::wantHtmlList([ + $element, + $this->assembleErrors() + ])); + } else { + $this->addHtml(...Html::wantHtmlList([ + $this->assembleLabel(), + $this->assembleElement(), + $this->assembleDescription(), + $this->assembleErrors() + ])); + } + } +} diff --git a/vendor/ipl/html/src/FormElement/BaseFormElement.php b/vendor/ipl/html/src/FormElement/BaseFormElement.php new file mode 100644 index 0000000..837ac45 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/BaseFormElement.php @@ -0,0 +1,390 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\ValueCandidates; +use ipl\Html\Form; +use ipl\I18n\Translation; +use ipl\Stdlib\Messages; +use ipl\Validator\ValidatorChain; +use ReflectionProperty; + +abstract class BaseFormElement extends BaseHtmlElement implements FormElement, ValueCandidates +{ + use Messages; + use Translation; + + /** @var string Description of the element */ + protected $description; + + /** @var string Label of the element */ + protected $label; + + /** @var string Name of the element */ + protected $name; + + /** @var bool Whether the element is ignored */ + protected $ignored = false; + + /** @var bool Whether the element is required */ + protected $required = false; + + /** @var null|bool Whether the element is valid; null if the element has not been validated yet, bool otherwise */ + protected $valid; + + /** @var ValidatorChain Registered validators */ + protected $validators; + + /** @var mixed Value of the element */ + protected $value; + + /** @var array<int, mixed> Value candidates of the element */ + protected $valueCandidates = []; + + /** + * Create a new form element + * + * @param string $name Name of the form element + * @param mixed $attributes Attributes of the form element + */ + public function __construct($name, $attributes = null) + { + $this->setName($name); + $this->init(); + + if ($attributes !== null) { + $this->addAttributes($attributes); + } + } + + public function getDescription() + { + return $this->description; + } + + /** + * Set the description of the element + * + * @param string $description + * + * @return $this + */ + public function setDescription($description) + { + $this->description = $description; + + return $this; + } + + public function getLabel() + { + return $this->label; + } + + /** + * Set the label of the element + * + * @param string $label + * + * @return $this + */ + public function setLabel($label) + { + $this->label = $label; + + return $this; + } + + public function getName() + { + return $this->name; + } + + /** + * Set the name for the element + * + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + public function isIgnored() + { + return $this->ignored; + } + + /** + * Set whether the element is ignored + * + * @param bool $ignored + * + * @return $this + */ + public function setIgnored($ignored = true) + { + $this->ignored = (bool) $ignored; + + return $this; + } + + public function isRequired() + { + return $this->required; + } + + /** + * Set whether the element is required + * + * @param bool $required + * + * @return $this + */ + public function setRequired($required = true) + { + $this->required = (bool) $required; + + return $this; + } + + public function isValid() + { + if ($this->valid === null) { + $this->validate(); + } + + return $this->valid; + } + + /** + * Get the validators + * + * @return ValidatorChain + */ + public function getValidators() + { + if ($this->validators === null) { + $chain = new ValidatorChain(); + $this->addDefaultValidators($chain); + $this->validators = $chain; + } + + return $this->validators; + } + + /** + * Set the validators + * + * @param iterable $validators + * + * @return $this + */ + public function setValidators($validators) + { + $this + ->getValidators() + ->clearValidators() + ->addValidators($validators); + + return $this; + } + + /** + * Add validators + * + * @param iterable $validators + * + * @return $this + */ + public function addValidators($validators) + { + $this->getValidators()->addValidators($validators); + + return $this; + } + + public function hasValue() + { + $value = $this->getValue(); + + return ! Form::isEmptyValue($value); + } + + public function getValue() + { + return $this->value; + } + + public function setValue($value) + { + if ($value === '') { + $this->value = null; + } else { + $this->value = $value; + } + + $this->valid = null; + + return $this; + } + + public function getValueCandidates() + { + return $this->valueCandidates; + } + + public function setValueCandidates(array $values) + { + $this->valueCandidates = $values; + + return $this; + } + + public function onRegistered(Form $form) + { + } + + /** + * Validate the element using all registered validators + * + * @return $this + */ + public function validate() + { + $this->ensureAssembled(); + + if (! $this->hasValue()) { + if ($this->isRequired()) { + $this->setMessages([$this->translate('This field is required.')]); + $this->valid = false; + } else { + $this->valid = true; + } + } else { + $this->valid = $this->getValidators()->isValid($this->getValue()); + $this->setMessages($this->getValidators()->getMessages()); + } + + return $this; + } + + public function hasBeenValidated() + { + return $this->valid !== null; + } + + /** + * Callback for the name attribute + * + * @return Attribute|string + */ + public function getNameAttribute() + { + return $this->getName(); + } + + /** + * Callback for the required attribute + * + * @return Attribute|null + */ + public function getRequiredAttribute() + { + if ($this->isRequired()) { + return new Attribute('required', true); + } + + return null; + } + + /** + * Callback for the value attribute + * + * @return mixed + */ + public function getValueAttribute() + { + return $this->getValue(); + } + + /** + * Initialize this form element + * + * If you want to initialize this element after construction, override this method + */ + protected function init(): void + { + } + + /** + * Add default validators + */ + protected function addDefaultValidators(ValidatorChain $chain): void + { + } + + protected function registerValueCallback(Attributes $attributes) + { + $attributes->registerAttributeCallback( + 'value', + [$this, 'getValueAttribute'], + [$this, 'setValue'] + ); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + $this->registerValueCallback($attributes); + + $attributes + ->registerAttributeCallback('label', null, [$this, 'setLabel']) + ->registerAttributeCallback('name', [$this, 'getNameAttribute'], [$this, 'setName']) + ->registerAttributeCallback('description', null, [$this, 'setDescription']) + ->registerAttributeCallback('validators', null, [$this, 'setValidators']) + ->registerAttributeCallback('ignore', null, [$this, 'setIgnored']) + ->registerAttributeCallback('required', [$this, 'getRequiredAttribute'], [$this, 'setRequired']); + + $this->registerCallbacks(); + } + + /** @deprecated Use {@link registerAttributeCallbacks()} instead */ + protected function registerCallbacks() + { + } + + /** + * @deprecated + * + * {@see Attributes::get()} does not respect callbacks, + * but we need the value of the callback to nest attribute names. + */ + protected function getValueOfNameAttribute() + { + $attributes = $this->getAttributes(); + + $callbacksProperty = new ReflectionProperty(get_class($attributes), 'callbacks'); + $callbacksProperty->setAccessible(true); + $callbacks = $callbacksProperty->getValue($attributes); + + if (isset($callbacks['name'])) { + $name = $callbacks['name'](); + + if ($name instanceof Attribute) { + return $name->getValue(); + } + + return $name; + } + + return $this->getName(); + } +} diff --git a/vendor/ipl/html/src/FormElement/ButtonElement.php b/vendor/ipl/html/src/FormElement/ButtonElement.php new file mode 100644 index 0000000..63ae540 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/ButtonElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class ButtonElement extends BaseFormElement +{ + protected $tag = 'button'; +} diff --git a/vendor/ipl/html/src/FormElement/CheckboxElement.php b/vendor/ipl/html/src/FormElement/CheckboxElement.php new file mode 100644 index 0000000..37e036a --- /dev/null +++ b/vendor/ipl/html/src/FormElement/CheckboxElement.php @@ -0,0 +1,124 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; + +class CheckboxElement extends InputElement +{ + /** @var bool Whether the checkbox is checked */ + protected $checked = false; + + /** @var string Value of the checkbox when it is checked */ + protected $checkedValue = 'y'; + + /** @var string Value of the checkbox when it is not checked */ + protected $uncheckedValue = 'n'; + + protected $type = 'checkbox'; + + /** + * Get whether the checkbox is checked + * + * @return bool + */ + public function isChecked() + { + return $this->checked; + } + + /** + * Set whether the checkbox is checked + * + * @param bool $checked + * + * @return $this + */ + public function setChecked($checked) + { + $this->checked = (bool) $checked; + + return $this; + } + + /** + * Get the value of the checkbox when it is checked + * + * @return string + */ + public function getCheckedValue() + { + return $this->checkedValue; + } + + /** + * Set the value of the checkbox when it is checked + * + * @param string $checkedValue + * + * @return $this + */ + public function setCheckedValue($checkedValue) + { + $this->checkedValue = $checkedValue; + + return $this; + } + + /** + * Get the value of the checkbox when it is not checked + * + * @return string + */ + public function getUncheckedValue() + { + return $this->uncheckedValue; + } + + /** + * Set the value of the checkbox when it is not checked + * + * @param string $uncheckedValue + * + * @return $this + */ + public function setUncheckedValue($uncheckedValue) + { + $this->uncheckedValue = $uncheckedValue; + + return $this; + } + + public function setValue($value) + { + if (is_bool($value)) { + $value = $value ? $this->getCheckedValue() : $this->getUncheckedValue(); + } + + $this->setChecked($value === $this->getCheckedValue()); + + return parent::setValue($value); + } + + public function getValueAttribute() + { + return $this->getCheckedValue(); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes + ->registerAttributeCallback('checked', [$this, 'isChecked'], [$this, 'setChecked']) + ->registerAttributeCallback('checkedValue', null, [$this, 'setCheckedValue']) + ->registerAttributeCallback('uncheckedValue', null, [$this, 'setUncheckedValue']); + } + + public function renderUnwrapped() + { + $html = parent::renderUnwrapped(); + + return (new HiddenElement($this->getValueOfNameAttribute(), ['value' => $this->getUncheckedValue()])) . $html; + } +} diff --git a/vendor/ipl/html/src/FormElement/ColorElement.php b/vendor/ipl/html/src/FormElement/ColorElement.php new file mode 100644 index 0000000..21d6c3a --- /dev/null +++ b/vendor/ipl/html/src/FormElement/ColorElement.php @@ -0,0 +1,16 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Validator\HexColorValidator; +use ipl\Validator\ValidatorChain; + +class ColorElement extends InputElement +{ + protected $type = 'color'; + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new HexColorValidator()); + } +} diff --git a/vendor/ipl/html/src/FormElement/DateElement.php b/vendor/ipl/html/src/FormElement/DateElement.php new file mode 100644 index 0000000..2f73b3c --- /dev/null +++ b/vendor/ipl/html/src/FormElement/DateElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class DateElement extends InputElement +{ + protected $type = 'date'; +} diff --git a/vendor/ipl/html/src/FormElement/FieldsetElement.php b/vendor/ipl/html/src/FormElement/FieldsetElement.php new file mode 100644 index 0000000..0d70ea4 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/FieldsetElement.php @@ -0,0 +1,122 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\Contract\Wrappable; +use LogicException; + +use function ipl\Stdlib\get_php_type; + +class FieldsetElement extends BaseFormElement +{ + use FormElements { + FormElements::getValue as private getElementValue; + } + + protected $tag = 'fieldset'; + + /** + * Get whether any of this set's elements has a value + * + * @return bool + */ + public function hasValue() + { + foreach ($this->getElements() as $element) { + if ($element->hasValue()) { + return true; + } + } + + return false; + } + + public function getValue($name = null, $default = null) + { + if ($name === null) { + if ($default !== null) { + throw new LogicException("Can't provide default without a name"); + } + + return $this->getValues(); + } + + return $this->getElementValue($name, $default); + } + + public function setValue($value) + { + if (! is_iterable($value)) { + throw new InvalidArgumentException( + sprintf( + '%s expects parameter $value to be an array|iterable, got %s instead', + __METHOD__, + get_php_type($value) + ) + ); + } + + // We expect an array/iterable here, + // so call populate to loop through it and apply values to the child elements of the fieldset. + $this->populate($value); + + return $this; + } + + public function validate() + { + $this->ensureAssembled(); + + $this->valid = true; + foreach ($this->getElements() as $element) { + $element->validate(); + if (! $element->isValid()) { + $this->valid = false; + } + } + + if ($this->valid) { + parent::validate(); + } + + return $this; + } + + public function getValueAttribute() + { + // Fieldsets do not have the value attribute. + return null; + } + + public function setWrapper(Wrappable $wrapper) + { + // TODO(lippserd): Revise decorator implementation to properly implement decorator propagation + if ( + ! $this->hasDefaultElementDecorator() + && $wrapper instanceof FormElementDecorator + ) { + $this->setDefaultElementDecorator(clone $wrapper); + } + + return parent::setWrapper($wrapper); + } + + protected function onElementRegistered(FormElement $element) + { + $element->getAttributes()->registerAttributeCallback('name', function () use ($element) { + /** + * We don't change the {@see BaseFormElement::$name} property of the element, + * otherwise methods like {@see FormElements::populate() and {@see FormElements::getElement()} would fail, + * but only change the name attribute to nest the names. + */ + return sprintf( + '%s[%s]', + $this->getValueOfNameAttribute(), + $element->getName() + ); + }); + } +} diff --git a/vendor/ipl/html/src/FormElement/FileElement.php b/vendor/ipl/html/src/FormElement/FileElement.php new file mode 100644 index 0000000..d9ca8fd --- /dev/null +++ b/vendor/ipl/html/src/FormElement/FileElement.php @@ -0,0 +1,414 @@ +<?php + +namespace ipl\Html\FormElement; + +use GuzzleHttp\Psr7\UploadedFile; +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\Form; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Validator\FileValidator; +use ipl\Validator\ValidatorChain; +use Psr\Http\Message\UploadedFileInterface; +use ipl\Html\Common\MultipleAttribute; + +use function ipl\Stdlib\get_php_type; + +/** + * File upload element + * + * Once the file element is added to the form and the form attribute `enctype` is not set, + * it is automatically set to `multipart/form-data`. + */ +class FileElement extends InputElement +{ + use MultipleAttribute; + use Translation; + + protected $type = 'file'; + + /** @var UploadedFileInterface|UploadedFileInterface[] */ + protected $value; + + /** @var UploadedFileInterface[] Files that are stored on disk */ + protected $files = []; + + /** @var string[] Files to be removed from disk */ + protected $filesToRemove = []; + + /** @var ?string Path to store files to preserve them across requests */ + protected $destination; + + /** @var int The default maximum file size */ + protected static $defaultMaxFileSize; + + public function __construct($name, $attributes = null) + { + $this->getAttributes()->get('accept')->setSeparator(', '); + + parent::__construct($name, $attributes); + } + + /** + * Get the path to store files to preserve them across requests + * + * @return string + */ + public function getDestination(): ?string + { + return $this->destination; + } + + /** + * Set the path to store files to preserve them across requests + * + * Uploaded files are moved to the given directory to + * retain the file through automatic form submissions and failed form validations. + * + * Please note that using file persistence currently has the following drawbacks: + * + * * Works only if the file element is added to the form during {@link Form::assemble()}. + * * Persisted files are not removed automatically. + * * Files with the same name override each other. + * + * @param string $path + * + * @return $this + */ + public function setDestination(string $path): self + { + $this->destination = $path; + + return $this; + } + + public function getValueAttribute() + { + // Value attributes of file inputs are set only client-side. + return null; + } + + public function getNameAttribute() + { + $name = $this->getName(); + + return $this->isMultiple() ? ($name . '[]') : $name; + } + + public function hasValue() + { + if ($this->value === null) { + $files = $this->loadFiles(); + if (empty($files)) { + return false; + } + + if (! $this->isMultiple()) { + $files = $files[0]; + } + + $this->value = $files; + } + + return $this->value !== null; + } + + public function setValue($value) + { + if (! empty($value)) { + $fileToTest = $value; + if ($this->isMultiple()) { + $fileToTest = $value[0]; + } + + if (! $fileToTest instanceof UploadedFileInterface) { + throw new InvalidArgumentException( + sprintf('%s is not an uploaded file', get_php_type($fileToTest)) + ); + } + + if ($fileToTest->getError() === UPLOAD_ERR_NO_FILE && ! $fileToTest->getClientFilename()) { + // This is checked here as it's only about file elements for which no value has been chosen + $value = null; + } else { + $files = $value; + if (! $this->isMultiple()) { + $files = [$files]; + } + + /** @var UploadedFileInterface[] $files */ + $storedFiles = $this->storeFiles(...$files); + if (! $this->isMultiple()) { + $storedFiles = $storedFiles[0] ?? null; + } + + $value = $storedFiles; + } + } else { + $value = null; + } + + return parent::setValue($value); + } + + /** + * Get whether there are any files stored on disk + * + * @return bool + */ + protected function hasFiles(): bool + { + return $this->destination !== null && reset($this->files); + } + + /** + * Load and return all files stored on disk + * + * @return UploadedFileInterface[] + */ + protected function loadFiles(): array + { + if (empty($this->files) || $this->destination === null) { + return []; + } + + foreach ($this->files as $name => $_) { + $filePath = $this->getFilePath($name); + if (! is_readable($filePath) || ! is_file($filePath)) { + // If one file isn't accessible, none is + return []; + } + + if (in_array($name, $this->filesToRemove, true)) { + @unlink($filePath); + } else { + $this->files[$name] = new UploadedFile( + $filePath, + filesize($filePath) ?: null, + 0, + $name, + mime_content_type($filePath) ?: null + ); + } + } + + $this->files = array_diff_key($this->files, array_flip($this->filesToRemove)); + + return array_values($this->files); + } + + /** + * Store the given files on disk + * + * @param UploadedFileInterface ...$files + * + * @return UploadedFileInterface[] + */ + protected function storeFiles(UploadedFileInterface ...$files): array + { + if ($this->destination === null || ! is_writable($this->destination)) { + return $files; + } + + $storedFiles = []; + foreach ($files as $file) { + $name = $file->getClientFilename(); + $path = $this->getFilePath($name); + + if ($file->getError() !== UPLOAD_ERR_OK) { + // The file is still returned as otherwise it won't be validated + $storedFiles[] = $file; + continue; + } + + $file->moveTo($path); + + // Re-created to ensure moveTo() still works if called externally + $file = new UploadedFile( + $path, + $file->getSize(), + 0, + $name, + $file->getClientMediaType() + ); + + $this->files[$name] = $file; + $storedFiles[] = $file; + } + + return $storedFiles; + } + + /** + * Get the file path on disk of the given file + * + * @param string $name + * + * @return string + */ + protected function getFilePath(string $name): string + { + return implode(DIRECTORY_SEPARATOR, [$this->destination, sha1($name)]); + } + + public function onRegistered(Form $form) + { + if (! $form->hasAttribute('enctype')) { + $form->setAttribute('enctype', 'multipart/form-data'); + } + + $chosenFiles = (array) $form->getPopulatedValue('chosen_file_' . $this->getName(), []); + foreach ($chosenFiles as $chosenFile) { + $this->files[$chosenFile] = null; + } + + $this->filesToRemove = (array) $form->getPopulatedValue('remove_file_' . $this->getName(), []); + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new FileValidator([ + 'maxSize' => $this->getDefaultMaxFileSize(), + 'mimeType' => array_filter( + (array) $this->getAttributes()->get('accept')->getValue(), + function ($type) { + // file inputs also allow file extensions in the accept attribute. These + // must not be passed as they don't resemble valid mime type definitions. + return is_string($type) && ltrim($type)[0] !== '.'; + } + ) + ])); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + $this->registerMultipleAttributeCallback($attributes); + $this->getAttributes()->registerAttributeCallback('destination', null, [$this, 'setDestination']); + } + + /** + * Get the system's default maximum file upload size + * + * @return int + */ + public function getDefaultMaxFileSize(): int + { + if (static::$defaultMaxFileSize === null) { + $ini = $this->convertIniToInteger(trim(static::getPostMaxSize())); + $max = $this->convertIniToInteger(trim(static::getUploadMaxFilesize())); + $min = max($ini, $max); + if ($ini > 0) { + $min = min($min, $ini); + } + + if ($max > 0) { + $min = min($min, $max); + } + + static::$defaultMaxFileSize = $min; + } + + return static::$defaultMaxFileSize; + } + + /** + * Converts a ini setting to a integer value + * + * @param string $setting + * + * @return int + */ + private function convertIniToInteger(string $setting): int + { + if (! is_numeric($setting)) { + $type = strtoupper(substr($setting, -1)); + $setting = (int) substr($setting, 0, -1); + + switch ($type) { + case 'K': + $setting *= 1024; + break; + + case 'M': + $setting *= 1024 * 1024; + break; + + case 'G': + $setting *= 1024 * 1024 * 1024; + break; + + default: + break; + } + } + + return (int) $setting; + } + + /** + * Get the `post_max_size` INI setting + * + * @return string + */ + protected static function getPostMaxSize(): string + { + return ini_get('post_max_size') ?: '8M'; + } + + /** + * Get the `upload_max_filesize` INI setting + * + * @return string + */ + protected static function getUploadMaxFilesize(): string + { + return ini_get('upload_max_filesize') ?: '2M'; + } + + protected function assemble() + { + $doc = new HtmlDocument(); + if ($this->hasFiles()) { + foreach ($this->files as $file) { + $doc->addHtml(new HiddenElement('chosen_file_' . $this->getValueOfNameAttribute(), [ + 'value' => $file->getClientFilename() + ])); + } + + $this->prependWrapper($doc); + } + } + + public function renderUnwrapped() + { + if (! $this->hasValue() || ! $this->hasFiles()) { + return parent::renderUnwrapped(); + } + + $uploadedFiles = new HtmlElement('ul', Attributes::create(['class' => 'uploaded-files'])); + foreach ($this->files as $file) { + $uploadedFiles->addHtml(new HtmlElement( + 'li', + null, + (new ButtonElement('remove_file_' . $this->getValueOfNameAttribute(), Attributes::create([ + 'type' => 'submit', + 'formnovalidate' => true, + 'class' => 'remove-uploaded-file', + 'value' => $file->getClientFilename(), + 'title' => sprintf($this->translate('Remove file "%s"'), $file->getClientFilename()) + ])))->addHtml(new HtmlElement( + 'span', + null, + new HtmlElement('i', Attributes::create(['class' => ['icon', 'fa', 'fa-xmark']])), + Text::create($file->getClientFilename()) + )) + )); + } + + return $uploadedFiles->render(); + } +} diff --git a/vendor/ipl/html/src/FormElement/FormElements.php b/vendor/ipl/html/src/FormElement/FormElements.php new file mode 100644 index 0000000..4a2c598 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/FormElements.php @@ -0,0 +1,509 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Contract\FormElement; +use ipl\Html\Contract\FormElementDecorator; +use ipl\Html\Contract\ValueCandidates; +use ipl\Html\Form; +use ipl\Html\FormDecorator\DecoratorInterface; +use ipl\Html\ValidHtml; +use ipl\Stdlib\Events; +use ipl\Stdlib\Plugins; +use UnexpectedValueException; + +use function ipl\Stdlib\get_php_type; + +trait FormElements +{ + use Events; + use Plugins; + + /** @var FormElementDecorator|null */ + private $defaultElementDecorator; + + /** @var bool Whether the default element decorator loader has been registered */ + protected $defaultElementDecoratorLoaderRegistered = false; + + /** @var bool Whether the default element loader has been registered */ + protected $defaultElementLoaderRegistered = false; + + /** @var FormElement[] */ + private $elements = []; + + /** @var array<string, array<int, mixed>> */ + private $populatedValues = []; + + /** + * Get all elements + * + * @return FormElement[] + */ + public function getElements() + { + return $this->elements; + } + + /** + * Get whether the given element exists + * + * @param string|FormElement $element + * + * @return bool + */ + public function hasElement($element) + { + if (is_string($element)) { + return array_key_exists($element, $this->elements); + } + + if ($element instanceof FormElement) { + return in_array($element, $this->elements, true); + } + + return false; + } + + /** + * Get the element by the given name + * + * @param string $name + * + * @return FormElement + * + * @throws InvalidArgumentException If no element with the given name exists + */ + public function getElement($name) + { + if (! array_key_exists($name, $this->elements)) { + throw new InvalidArgumentException(sprintf( + "Can't get element '%s'. Element does not exist", + $name + )); + } + + return $this->elements[$name]; + } + + /** + * Add an element + * + * @param string|FormElement $typeOrElement Type of the element as string or an instance of FormElement + * @param string $name Name of the element + * @param mixed $options Element options as key-value pairs + * + * @return $this + * + * @throws InvalidArgumentException If $typeOrElement is neither a string nor an instance of FormElement + * or if $typeOrElement is a string and $name is not set + * or if $typeOrElement is a string but type is unknown + * or if $typeOrElement is an instance of FormElement but does not have a name + */ + public function addElement($typeOrElement, $name = null, $options = null) + { + if (is_string($typeOrElement)) { + if ($name === null) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter 2 to be set if parameter 1 is a string', + __METHOD__ + )); + } + + $element = $this->createElement($typeOrElement, $name, $options); + } elseif ($typeOrElement instanceof FormElement) { + $element = $typeOrElement; + } else { + throw new InvalidArgumentException(sprintf( + '%s() expects parameter 1 to be a string or an instance of %s, %s given', + __METHOD__, + FormElement::class, + get_php_type($typeOrElement) + )); + } + + $this + ->registerElement($element) // registerElement() must be called first because of the name check + ->decorate($element) + ->addHtml($element); + + return $this; + } + + /** + * Create an element + * + * @param string $type Type of the element + * @param string $name Name of the element + * @param mixed $options Element options as key-value pairs + * + * @return FormElement + * + * @throws InvalidArgumentException If the type of the element is unknown + */ + public function createElement($type, $name, $options = null) + { + $this->ensureDefaultElementLoaderRegistered(); + + $class = $this->loadPlugin('element', $type); + + if (! $class) { + throw new InvalidArgumentException(sprintf( + "Can't create element of unknown type '%s", + $type + )); + } + + /** @var FormElement $element */ + $element = new $class($name); + + if ($options !== null) { + $element->addAttributes($options); + } + + return $element; + } + + /** + * Register an element + * + * Registers the element for value and validation handling but does not add it to the render stack. + * + * @param FormElement $element + * + * @return $this + * + * @throws InvalidArgumentException If $element does not provide a name + */ + public function registerElement(FormElement $element) + { + $name = $element->getName(); + + if ($name === null) { + throw new InvalidArgumentException(sprintf( + '%s expects the element to provide a name', + __METHOD__ + )); + } + + $this->elements[$name] = $element; + + if (array_key_exists($name, $this->populatedValues)) { + $element->setValue($this->populatedValues[$name][count($this->populatedValues[$name]) - 1]); + + if ($element instanceof ValueCandidates) { + $element->setValueCandidates($this->populatedValues[$name]); + } + } + + $this->onElementRegistered($element); + $this->emit(Form::ON_ELEMENT_REGISTERED, [$element]); + + return $this; + } + + /** + * Get whether a default element decorator exists + * + * @return bool + */ + public function hasDefaultElementDecorator() + { + return $this->defaultElementDecorator !== null; + } + + /** + * Get the default element decorator, if any + * + * @return FormElementDecorator|null + */ + public function getDefaultElementDecorator() + { + return $this->defaultElementDecorator; + } + + /** + * Set the default element decorator + * + * If $decorator is a string, the decorator will be automatically created from a registered decorator loader. + * A loader for the namespace ipl\\Html\\FormDecorator is automatically registered by default. + * See {@link addDecoratorLoader()} for registering a custom loader. + * + * @param FormElementDecorator|string $decorator + * + * @return $this + * + * @throws InvalidArgumentException If $decorator is a string and can't be loaded from registered decorator loaders + * or if a decorator loader does not return an instance of + * {@link FormElementDecorator} + */ + public function setDefaultElementDecorator($decorator) + { + if ($decorator instanceof FormElementDecorator || $decorator instanceof DecoratorInterface) { + $this->defaultElementDecorator = $decorator; + } else { + $this->ensureDefaultElementDecoratorLoaderRegistered(); + + $class = $this->loadPlugin('decorator', $decorator); + if (! $class) { + throw new InvalidArgumentException(sprintf( + "Can't create decorator of unknown type '%s", + $decorator + )); + } + + $d = new $class(); + if (! $d instanceof FormElementDecorator && ! $d instanceof DecoratorInterface) { + throw new InvalidArgumentException(sprintf( + "Expected instance of %s for decorator '%s'," + . " got %s from a decorator loader instead", + FormElementDecorator::class, + $decorator, + get_php_type($d) + )); + } + + $this->defaultElementDecorator = $d; + } + + return $this; + } + + /** + * Get the value of the element specified by name + * + * Returns $default if the element does not exist or has no value. + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getValue($name, $default = null) + { + if ($this->hasElement($name)) { + $value = $this->getElement($name)->getValue(); + if ($value !== null) { + return $value; + } + } + + return $default; + } + + /** + * Get the values for all but ignored elements + * + * @return array Values as name-value pairs + */ + public function getValues() + { + $values = []; + foreach ($this->getElements() as $element) { + if (! $element->isIgnored()) { + $values[$element->getName()] = $element->getValue(); + } + } + + return $values; + } + + /** + * Populate values of registered elements + * + * @param iterable<string, mixed> $values Values as name-value pairs + * + * @return $this + */ + public function populate($values) + { + foreach ($values as $name => $value) { + $this->populatedValues[$name][] = $value; + if ($this->hasElement($name)) { + $this->getElement($name)->setValue($value); + } + } + + return $this; + } + + /** + * Get the populated value of the element specified by name + * + * Returns $default if there is no populated value for this element. + * + * @param string $name + * @param mixed $default + * + * @return mixed + */ + public function getPopulatedValue($name, $default = null) + { + return isset($this->populatedValues[$name]) + ? $this->populatedValues[$name][count($this->populatedValues[$name]) - 1] + : $default; + } + + /** + * Clear populated value of the given element + * + * @param string $name + * + * @return $this + */ + public function clearPopulatedValue($name) + { + if (isset($this->populatedValues[$name])) { + unset($this->populatedValues[$name]); + } + + return $this; + } + + /** + * Add all elements from the given element collection + * + * @param Form $form + * + * @return $this + */ + public function addElementsFrom($form) + { + foreach ($form->getElements() as $element) { + $this->addElement($element); + } + + return $this; + } + + /** + * Add a decorator loader + * + * @param string $namespace Namespace of the decorators + * @param string $postfix Decorator name postfix, if any + * + * @return $this + */ + public function addDecoratorLoader($namespace, $postfix = null) + { + $this->addPluginLoader('decorator', $namespace, $postfix); + + return $this; + } + + /** + * Add an element loader + * + * @param string $namespace Namespace of the elements + * @param string $postfix Element name postfix, if any + * + * @return $this + */ + public function addElementLoader($namespace, $postfix = null) + { + $this->addPluginLoader('element', $namespace, $postfix); + + return $this; + } + + /** + * Ensure that our default element decorator loader is registered + * + * @return $this + */ + protected function ensureDefaultElementDecoratorLoaderRegistered() + { + if (! $this->defaultElementDecoratorLoaderRegistered) { + $this->addDefaultPluginLoader( + 'decorator', + 'ipl\\Html\\FormDecorator', + 'Decorator' + ); + + $this->defaultElementDecoratorLoaderRegistered = true; + } + + return $this; + } + + /** + * Ensure that our default element loader is registered + * + * @return $this + */ + protected function ensureDefaultElementLoaderRegistered() + { + if (! $this->defaultElementLoaderRegistered) { + $this->addDefaultPluginLoader('element', __NAMESPACE__, 'Element'); + + $this->defaultElementLoaderRegistered = true; + } + + return $this; + } + + /** + * Decorate the given element + * + * @param FormElement $element + * + * @return $this + * + * @throws UnexpectedValueException If the default decorator is set but not an instance of + * {@link FormElementDecorator} + */ + protected function decorate(FormElement $element) + { + if ($this->hasDefaultElementDecorator()) { + $decorator = $this->getDefaultElementDecorator(); + + if (! $decorator instanceof FormElementDecorator && ! $decorator instanceof DecoratorInterface) { + throw new UnexpectedValueException(sprintf( + '%s expects the default decorator to be an instance of %s, got %s instead', + __METHOD__, + FormElementDecorator::class, + get_php_type($decorator) + )); + } + + $decorator->decorate($element); + } + + return $this; + } + + public function isValidEvent($event) + { + return in_array($event, [ + Form::ON_SUCCESS, + Form::ON_SENT, + Form::ON_ERROR, + Form::ON_REQUEST, + Form::ON_VALIDATE, + Form::ON_ELEMENT_REGISTERED, + ]); + } + + public function remove(ValidHtml $elementOrHtml) + { + if ($elementOrHtml instanceof FormElement) { + if ($this->hasElement($elementOrHtml)) { + $name = array_search($elementOrHtml, $this->elements, true); + if ($name !== false) { + unset($this->elements[$name]); + } + } + } + + return parent::remove($elementOrHtml); + } + + /** + * Handler which is called after an element has been registered + * + * @param FormElement $element + */ + protected function onElementRegistered(FormElement $element) + { + } +} diff --git a/vendor/ipl/html/src/FormElement/HiddenElement.php b/vendor/ipl/html/src/FormElement/HiddenElement.php new file mode 100644 index 0000000..bffc7eb --- /dev/null +++ b/vendor/ipl/html/src/FormElement/HiddenElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class HiddenElement extends InputElement +{ + protected $type = 'hidden'; +} diff --git a/vendor/ipl/html/src/FormElement/InputElement.php b/vendor/ipl/html/src/FormElement/InputElement.php new file mode 100644 index 0000000..d5f945d --- /dev/null +++ b/vendor/ipl/html/src/FormElement/InputElement.php @@ -0,0 +1,49 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Attributes; + +class InputElement extends BaseFormElement +{ + /** @var string Type of the input */ + protected $type; + + protected $tag = 'input'; + + /** + * Get the type of the input + * + * @return string + */ + public function getType() + { + return $this->type; + } + + /** + * Set the type of the input + * + * @param string $type + * + * @return $this + */ + public function setType($type) + { + $this->type = (string) $type; + + return $this; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'type', + [$this, 'getType'], + [$this, 'setType'] + ); + } +} diff --git a/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php b/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php new file mode 100644 index 0000000..a628b57 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/LocalDateTimeElement.php @@ -0,0 +1,53 @@ +<?php + +namespace ipl\Html\FormElement; + +use DateTime; +use ipl\Validator\DateTimeValidator; +use ipl\Validator\ValidatorChain; + +class LocalDateTimeElement extends InputElement +{ + public const FORMAT = 'Y-m-d\TH:i:s'; + + protected $type = 'datetime-local'; + + protected $defaultAttributes = ['step' => '1']; + + /** @var DateTime */ + protected $value; + + public function setValue($value) + { + if (is_string($value)) { + $originalVal = $value; + $value = DateTime::createFromFormat(static::FORMAT, $value); + // In Chrome, if the seconds are set to 00, DateTime::createFromFormat() returns false. + // Create DateTime without seconds in format + if ($value === false) { + $format = substr(static::FORMAT, 0, strrpos(static::FORMAT, ':') ?: null); + $value = DateTime::createFromFormat($format, $originalVal); + } + + if ($value === false) { + $value = $originalVal; + } + } + + return parent::setValue($value); + } + + public function getValueAttribute() + { + if (! $this->value instanceof DateTime) { + return $this->value; + } + + return $this->value->format(static::FORMAT); + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new DateTimeValidator()); + } +} diff --git a/vendor/ipl/html/src/FormElement/NumberElement.php b/vendor/ipl/html/src/FormElement/NumberElement.php new file mode 100644 index 0000000..b593135 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/NumberElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class NumberElement extends InputElement +{ + protected $type = 'number'; +} diff --git a/vendor/ipl/html/src/FormElement/PasswordElement.php b/vendor/ipl/html/src/FormElement/PasswordElement.php new file mode 100644 index 0000000..dfa6d8c --- /dev/null +++ b/vendor/ipl/html/src/FormElement/PasswordElement.php @@ -0,0 +1,55 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; +use ipl\Html\Form; + +class PasswordElement extends InputElement +{ + /** @var string Dummy passwd of this element to be rendered */ + public const DUMMYPASSWORD = '_ipl_form_5847ed1b5b8ca'; + + protected $type = 'password'; + + /** @var bool Status of the form */ + protected $isFormValid = true; + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'value', + function () { + if ($this->hasValue() && count($this->getValueCandidates()) === 1 && $this->isFormValid) { + return self::DUMMYPASSWORD; + } + + if (parent::getValue() === self::DUMMYPASSWORD) { + return self::DUMMYPASSWORD; + } + + return null; + } + ); + } + + public function onRegistered(Form $form) + { + $form->on(Form::ON_VALIDATE, function ($form) { + $this->isFormValid = $form->isValid(); + }); + } + + public function getValue() + { + $value = parent::getValue(); + $candidates = $this->getValueCandidates(); + while ($value === self::DUMMYPASSWORD) { + $value = array_pop($candidates); + } + + return $value; + } +} diff --git a/vendor/ipl/html/src/FormElement/RadioElement.php b/vendor/ipl/html/src/FormElement/RadioElement.php new file mode 100644 index 0000000..831671c --- /dev/null +++ b/vendor/ipl/html/src/FormElement/RadioElement.php @@ -0,0 +1,177 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\HtmlDocument; +use ipl\Html\HtmlElement; +use ipl\Html\Text; +use ipl\I18n\Translation; +use ipl\Validator\DeferredInArrayValidator; +use ipl\Validator\ValidatorChain; + +class RadioElement extends BaseFormElement +{ + use Translation; + + /** @var string The element type */ + protected $type = 'radio'; + + /** @var RadioOption[] Radio options */ + protected $options = []; + + /** @var array Disabled radio options */ + protected $disabledOptions = []; + + /** + * Set the options + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options): self + { + $this->options = []; + foreach ($options as $value => $label) { + $option = (new RadioOption($value, $label)) + ->setDisabled( + in_array($value, $this->disabledOptions, ! is_int($value)) + || ($value === '' && in_array(null, $this->disabledOptions, true)) + ); + + $this->options[$value] = $option; + } + + $this->disabledOptions = []; + + return $this; + } + + /** + * Get the option with specified value + * + * @param string|int $value + * + * @return RadioOption + * + * @throws InvalidArgumentException If no option with the specified value exists + */ + public function getOption($value): RadioOption + { + if (! isset($this->options[$value])) { + throw new InvalidArgumentException(sprintf('There is no such option "%s"', $value)); + } + + return $this->options[$value]; + } + + /** + * Set the specified options as disable + * + * @param array $disabledOptions + * + * @return $this + */ + public function setDisabledOptions(array $disabledOptions): self + { + if (! empty($this->options)) { + foreach ($this->options as $value => $option) { + $option->setDisabled( + in_array($value, $disabledOptions, ! is_int($value)) + || ($value === '' && in_array(null, $disabledOptions, true)) + ); + } + + $this->disabledOptions = []; + } else { + $this->disabledOptions = $disabledOptions; + } + + return $this; + } + + public function renderUnwrapped() + { + // Parent::renderUnwrapped() requires $tag and the content should be empty. However, since we are wrapping + // each button in a label, the call to parent cannot work here and must be overridden. + return HtmlDocument::renderUnwrapped(); + } + + protected function assemble() + { + foreach ($this->options as $option) { + $radio = (new InputElement($this->getValueOfNameAttribute())) + ->setType($this->type) + ->setValue($option->getValue()); + + // Only add the non-callback attributes to all options + foreach ($this->getAttributes() as $attribute) { + $radio->getAttributes()->addAttribute(clone $attribute); + } + + $radio->getAttributes() + ->merge($option->getAttributes()) + ->registerAttributeCallback( + 'checked', + function () use ($option) { + $optionValue = $option->getValue(); + + return ! is_int($optionValue) + ? $this->getValue() === $optionValue + : $this->getValue() == $optionValue; + } + ) + ->registerAttributeCallback( + 'disabled', + function () use ($option) { + return $this->getAttributes()->get('disabled')->getValue() || $option->isDisabled(); + } + ); + + $label = new HtmlElement( + 'label', + new Attributes(['class' => $option->getLabelCssClass()]), + $radio, + Text::create($option->getLabel()) + ); + + $this->addHtml($label); + } + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add(new DeferredInArrayValidator(function (): array { + $possibleValues = []; + + foreach ($this->options as $option) { + if ($option->isDisabled()) { + continue; + } + + $possibleValues[] = $option->getValue(); + } + + return $possibleValues; + })); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $this->getAttributes()->registerAttributeCallback( + 'options', + null, + [$this, 'setOptions'] + ); + + $this->getAttributes()->registerAttributeCallback( + 'disabledOptions', + null, + [$this, 'setDisabledOptions'] + ); + } +} diff --git a/vendor/ipl/html/src/FormElement/RadioOption.php b/vendor/ipl/html/src/FormElement/RadioOption.php new file mode 100644 index 0000000..4968c35 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/RadioOption.php @@ -0,0 +1,148 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; + +class RadioOption +{ + /** @var string The default label class */ + public const LABEL_CLASS = 'radio-label'; + + /** @var string|int|null Value of the option */ + protected $value; + + /** @var string Label of the option */ + protected $label; + + /** @var mixed Css class of the option's label */ + protected $labelCssClass = self::LABEL_CLASS; + + /** @var bool Whether the radio option is disabled */ + protected $disabled = false; + + /** @var Attributes */ + protected $attributes; + + /** + * RadioOption constructor. + * + * @param string|int|null $value + * @param string $label + */ + public function __construct($value, string $label) + { + $this->value = $value === '' ? null : $value; + $this->label = $label; + } + + /** + * Set the label of the option + * + * @param string $label + * + * @return $this + */ + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Get the label of the option + * + * @return string + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * Get the value of the option + * + * @return string|int|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Set css class to the option label + * + * @param string|string[] $labelCssClass + * + * @return $this + */ + public function setLabelCssClass($labelCssClass): self + { + $this->labelCssClass = $labelCssClass; + + return $this; + } + + /** + * Get css class of the option label + * + * @return string|string[] + */ + public function getLabelCssClass() + { + return $this->labelCssClass; + } + + /** + * Set whether to disable the option + * + * @param bool $disabled + * + * @return $this + */ + public function setDisabled(bool $disabled = true): self + { + $this->disabled = $disabled; + + return $this; + } + + /** + * Get whether the option is disabled + * + * @return bool + */ + public function isDisabled(): bool + { + return $this->disabled; + } + + /** + * Add the attributes + * + * @param Attributes $attributes + * + * @return $this + */ + public function addAttributes(Attributes $attributes): self + { + $this->attributes = $attributes; + + return $this; + } + + /** + * Get the attributes + * + * @return Attributes + */ + public function getAttributes(): Attributes + { + if ($this->attributes === null) { + $this->attributes = new Attributes(); + } + + return $this->attributes; + } +} diff --git a/vendor/ipl/html/src/FormElement/SelectElement.php b/vendor/ipl/html/src/FormElement/SelectElement.php new file mode 100644 index 0000000..e6b4f21 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SelectElement.php @@ -0,0 +1,238 @@ +<?php + +namespace ipl\Html\FormElement; + +use InvalidArgumentException; +use ipl\Html\Attributes; +use ipl\Html\Common\MultipleAttribute; +use ipl\Html\Html; +use ipl\Html\HtmlElement; +use ipl\Validator\DeferredInArrayValidator; +use ipl\Validator\ValidatorChain; +use UnexpectedValueException; + +class SelectElement extends BaseFormElement +{ + use MultipleAttribute; + + protected $tag = 'select'; + + /** @var SelectOption[] */ + protected $options = []; + + /** @var SelectOption[]|HtmlElement[] */ + protected $optionContent = []; + + /** @var array Disabled select options */ + protected $disabledOptions = []; + + /** @var array|string|null */ + protected $value; + + /** + * Get the option with specified value + * + * @param string|int|null $value + * + * @return ?SelectOption + */ + public function getOption($value): ?SelectOption + { + return $this->options[$value] ?? null; + } + + /** + * Set the options from specified values + * + * @param array $options + * + * @return $this + */ + public function setOptions(array $options): self + { + $this->options = []; + $this->optionContent = []; + foreach ($options as $value => $label) { + $this->optionContent[$value] = $this->makeOption($value, $label); + } + + return $this; + } + + /** + * Set the specified options as disable + * + * @param array $disabledOptions + * + * @return $this + */ + public function setDisabledOptions(array $disabledOptions): self + { + if (! empty($this->options)) { + foreach ($this->options as $option) { + $optionValue = $option->getValue(); + + $option->setAttribute( + 'disabled', + in_array($optionValue, $disabledOptions, ! is_int($optionValue)) + || ($optionValue === null && in_array('', $disabledOptions, true)) + ); + } + + $this->disabledOptions = []; + } else { + $this->disabledOptions = $disabledOptions; + } + + return $this; + } + + /** + * Get the value of the element + * + * Returns `array` when the attribute `multiple` is set to `true`, `string` or `null` otherwise + * + * @return array|string|null + */ + public function getValue() + { + if ($this->isMultiple()) { + return parent::getValue() ?? []; + } + + return parent::getValue(); + } + + public function getValueAttribute() + { + // select elements don't have a value attribute + return null; + } + + public function getNameAttribute() + { + $name = $this->getName(); + + return $this->isMultiple() ? ($name . '[]') : $name; + } + + /** + * Make the selectOption for the specified value and the label + * + * @param string|int|null $value Value of the option + * @param string|array $label Label of the option + * + * @return SelectOption|HtmlElement + */ + protected function makeOption($value, $label) + { + if (is_array($label)) { + $grp = Html::tag('optgroup', ['label' => $value]); + foreach ($label as $option => $val) { + $grp->addHtml($this->makeOption($option, $val)); + } + + return $grp; + } + + $option = (new SelectOption($value, $label)) + ->setAttribute('disabled', in_array($value, $this->disabledOptions, ! is_int($value))); + + $option->getAttributes()->registerAttributeCallback('selected', function () use ($option) { + return $this->isSelectedOption($option->getValue()); + }); + + $this->options[$value] = $option; + + return $this->options[$value]; + } + + /** + * Get whether the given option is selected + * + * @param int|string|null $optionValue + * + * @return bool + */ + protected function isSelectedOption($optionValue): bool + { + $value = $this->getValue(); + + if ($optionValue === '') { + $optionValue = null; + } + + if ($this->isMultiple()) { + if (! is_array($value)) { + throw new UnexpectedValueException( + 'Value must be an array when the `multiple` attribute is set to `true`' + ); + } + + return in_array($optionValue, $this->getValue(), ! is_int($optionValue)) + || ($optionValue === null && in_array('', $this->getValue(), true)); + } + + if (is_array($value)) { + throw new UnexpectedValueException( + 'Value cannot be an array without setting the `multiple` attribute to `true`' + ); + } + + return is_int($optionValue) + // The loose comparison is required because PHP casts + // numeric strings to integers if used as array keys + ? $value == $optionValue + : $value === $optionValue; + } + + protected function addDefaultValidators(ValidatorChain $chain): void + { + $chain->add( + new DeferredInArrayValidator(function (): array { + $possibleValues = []; + + foreach ($this->options as $option) { + if ($option->getAttributes()->get('disabled')->getValue()) { + continue; + } + + $possibleValues[] = $option->getValue(); + } + + return $possibleValues; + }) + ); + } + + protected function assemble() + { + $this->addHtml(...array_values($this->optionContent)); + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback( + 'options', + null, + [$this, 'setOptions'] + ); + + $attributes->registerAttributeCallback( + 'disabledOptions', + null, + [$this, 'setDisabledOptions'] + ); + + // ZF1 compatibility: + $this->getAttributes()->registerAttributeCallback( + 'multiOptions', + null, + [$this, 'setOptions'] + ); + + $this->registerMultipleAttributeCallback($attributes); + } +} diff --git a/vendor/ipl/html/src/FormElement/SelectOption.php b/vendor/ipl/html/src/FormElement/SelectOption.php new file mode 100644 index 0000000..3d799a2 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SelectOption.php @@ -0,0 +1,79 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\BaseHtmlElement; + +class SelectOption extends BaseHtmlElement +{ + protected $tag = 'option'; + + /** @var string|int|null Value of the option */ + protected $value; + + /** @var string Label of the option */ + protected $label; + + /** + * SelectOption constructor. + * + * @param string|int|null $value + * @param string $label + */ + public function __construct($value, string $label) + { + $this->value = $value === '' ? null : $value; + $this->label = $label; + + $this->getAttributes()->registerAttributeCallback('value', [$this, 'getValueAttribute']); + } + + /** + * Set the label of the option + * + * @param string $label + * + * @return $this + */ + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * Get the label of the option + * + * @return string + */ + public function getLabel(): string + { + return $this->label; + } + + /** + * Get the value of the option + * + * @return string|int|null + */ + public function getValue() + { + return $this->value; + } + + /** + * Callback for the value attribute + * + * @return mixed + */ + public function getValueAttribute() + { + return (string) $this->getValue(); + } + + protected function assemble() + { + $this->setContent($this->getLabel()); + } +} diff --git a/vendor/ipl/html/src/FormElement/SubmitButtonElement.php b/vendor/ipl/html/src/FormElement/SubmitButtonElement.php new file mode 100644 index 0000000..b880bb5 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SubmitButtonElement.php @@ -0,0 +1,65 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attributes; +use ipl\Html\Contract\FormSubmitElement; + +class SubmitButtonElement extends ButtonElement implements FormSubmitElement +{ + protected $defaultAttributes = ['type' => 'submit']; + + /** @var string The value that's transmitted once the button is pressed */ + protected $submitValue = 'y'; + + /** + * Get the value to transmit once the button is pressed + * + * @return string + */ + public function getSubmitValue(): string + { + return $this->submitValue; + } + + /** + * Set the value to transmit once the button is pressed + * + * @param string $value + * + * @return $this + */ + public function setSubmitValue(string $value): self + { + $this->submitValue = $value; + + return $this; + } + + public function setLabel($label) + { + return $this->setContent($label); + } + + public function hasBeenPressed() + { + return $this->getValue() === $this->getSubmitValue(); + } + + public function isIgnored() + { + return true; + } + + protected function registerAttributeCallbacks(Attributes $attributes) + { + parent::registerAttributeCallbacks($attributes); + + $attributes->registerAttributeCallback('value', null, [$this, 'setSubmitValue']); + } + + public function getValueAttribute() + { + return $this->submitValue; + } +} diff --git a/vendor/ipl/html/src/FormElement/SubmitElement.php b/vendor/ipl/html/src/FormElement/SubmitElement.php new file mode 100644 index 0000000..51d4aa5 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/SubmitElement.php @@ -0,0 +1,50 @@ +<?php + +namespace ipl\Html\FormElement; + +use ipl\Html\Attribute; +use ipl\Html\Contract\FormSubmitElement; + +class SubmitElement extends InputElement implements FormSubmitElement +{ + protected $type = 'submit'; + + protected $buttonLabel; + + public function setLabel($label) + { + $this->buttonLabel = $label; + + return $this; + } + + /** + * @return string + */ + public function getButtonLabel() + { + if ($this->buttonLabel === null) { + return $this->getName(); + } else { + return $this->buttonLabel; + } + } + + /** + * @return mixed|static + */ + public function getValueAttribute() + { + return new Attribute('value', $this->getButtonLabel()); + } + + public function hasBeenPressed() + { + return $this->getButtonLabel() === $this->getValue(); + } + + public function isIgnored() + { + return true; + } +} diff --git a/vendor/ipl/html/src/FormElement/TextElement.php b/vendor/ipl/html/src/FormElement/TextElement.php new file mode 100644 index 0000000..0e3423d --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TextElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class TextElement extends InputElement +{ + protected $type = 'text'; +} diff --git a/vendor/ipl/html/src/FormElement/TextareaElement.php b/vendor/ipl/html/src/FormElement/TextareaElement.php new file mode 100644 index 0000000..dc5c42b --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TextareaElement.php @@ -0,0 +1,24 @@ +<?php + +namespace ipl\Html\FormElement; + +class TextareaElement extends BaseFormElement +{ + protected $tag = 'textarea'; + + public function setValue($value) + { + parent::setValue($value); + + // A textarea's content actually is the value + $this->setContent($value); + + return $this; + } + + public function getValueAttribute() + { + // textarea elements don't have a value attribute + return null; + } +} diff --git a/vendor/ipl/html/src/FormElement/TimeElement.php b/vendor/ipl/html/src/FormElement/TimeElement.php new file mode 100644 index 0000000..1ee0323 --- /dev/null +++ b/vendor/ipl/html/src/FormElement/TimeElement.php @@ -0,0 +1,8 @@ +<?php + +namespace ipl\Html\FormElement; + +class TimeElement extends InputElement +{ + protected $type = 'time'; +} diff --git a/vendor/ipl/html/src/FormattedString.php b/vendor/ipl/html/src/FormattedString.php new file mode 100644 index 0000000..1ef9b5b --- /dev/null +++ b/vendor/ipl/html/src/FormattedString.php @@ -0,0 +1,101 @@ +<?php + +namespace ipl\Html; + +use Exception; +use InvalidArgumentException; + +use function ipl\Stdlib\get_php_type; + +/** + * {@link sprintf()}-like formatted HTML string supporting lazy rendering of {@link ValidHtml} element arguments + * + * # Example Usage + * ``` + * $info = new FormattedString( + * 'Follow the %s for more information on %s', + * [ + * new Link('doc/html', 'HTML documentation'), + * Html::tag('strong', 'HTML elements') + * ] + * ); + * ``` + */ +class FormattedString implements ValidHtml +{ + /** @var ValidHtml[] */ + protected $args = []; + + /** @var ValidHtml */ + protected $format; + + /** + * Create a new {@link sprintf()}-like formatted HTML string + * + * @param string $format + * @param iterable $args + * + * @throws InvalidArgumentException If arguments given but not iterable + */ + public function __construct($format, $args = null) + { + $this->format = Html::wantHtml($format); + + if ($args !== null) { + if (! is_iterable($args)) { + throw new InvalidArgumentException(sprintf( + '%s expects parameter two to be iterable, got %s instead', + __METHOD__, + get_php_type($args) + )); + } + + foreach ($args as $key => $val) { + if (! is_scalar($val) || (is_string($val) && ! is_numeric($val))) { + $val = Html::wantHtml($val); + } + + $this->args[$key] = $val; + } + } + } + + + /** + * Create a new {@link sprintf()}-like formatted HTML string + * + * @param string $format + * @param mixed ...$args + * + * @return static + */ + public static function create($format, ...$args) + { + return new static($format, $args); + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + return vsprintf( + $this->format->render(), + $this->args + ); + } +} diff --git a/vendor/ipl/html/src/Html.php b/vendor/ipl/html/src/Html.php new file mode 100644 index 0000000..afcba39 --- /dev/null +++ b/vendor/ipl/html/src/Html.php @@ -0,0 +1,241 @@ +<?php + +namespace ipl\Html; + +use InvalidArgumentException; + +use function ipl\Stdlib\get_php_type; +use function ipl\Stdlib\iterable_key_first; + +/** + * Main utility class when working with ipl\Html + */ +abstract class Html +{ + /** + * Create a HTML element from the given tag, attributes and content + * + * This method does not render the HTML element but creates a {@link HtmlElement} + * instance from the given tag, attributes and content + * + * @param string $name The desired HTML tag name + * @param mixed $attributes HTML attributes or content for the element + * @param mixed $content The content of the element if no attributes have been given + * + * @return HtmlElement The created element + */ + public static function tag($name, $attributes = null, $content = null) + { + if ($content !== null) { + // If not null, it's html content, no question + $content = static::wantHtmlList($content); + } elseif ($attributes instanceof ValidHtml || is_scalar($attributes)) { + // Otherwise $attributes may be $content, but only if definitely **NOT** attributes + $content = static::wantHtmlList($attributes); + $attributes = null; + } + + if ($attributes !== null) { + if (! is_iterable($attributes) || ! is_int(iterable_key_first($attributes))) { + // Not an array (e.g. instance of Attributes) or an associative array + $attributes = Attributes::wantAttributes($attributes); + } elseif (is_iterable($attributes)) { + // $attributes may still be $content, in case of a sequenced array + if ($content !== null) { + // But not if there's already $content + throw new InvalidArgumentException('Value of argument $attributes are no attributes'); + } + + $content = static::wantHtmlList($attributes); + $attributes = null; + } + } + + return new HtmlElement($name, $attributes, ...($content ?: [])); + } + + /** + * Convert special characters to HTML5 entities using the UTF-8 character + * set for encoding + * + * This method internally uses {@link htmlspecialchars} with the following + * flags: + * + * * Single quotes are not escaped (ENT_COMPAT) + * * Uses HTML5 entities, disallowing 
 (ENT_HTML5) + * * Invalid characters are replaced with � (ENT_SUBSTITUTE) + * + * Already existing HTML entities will be encoded as well. + * + * @param string $content The content to encode + * + * @return string The encoded content + */ + public static function escape($content) + { + return htmlspecialchars($content, ENT_COMPAT | ENT_HTML5 | ENT_SUBSTITUTE, 'UTF-8'); + } + + /** + * Factory for {@link sprintf()}-like formatted HTML strings + * + * This allows to use {@link sprintf()}-like format strings with {@link ValidHtml} element arguments, but with the + * advantage that they'll not be rendered immediately. + * + * # Example Usage + * ``` + * echo Html::sprintf('Hello %s!', Html::tag('strong', $name)); + * ``` + * + * @param string $format + * @param mixed ...$args + * + * @return FormattedString + */ + public static function sprintf($format, ...$args) + { + return new FormattedString($format, $args); + } + + /** + * Wrap each item of then given list + * + * $wrapper is a simple HTML tag per entry if a string is given, + * otherwise the given callable is called with key and value of each list item as parameters. + * + * @param iterable $list + * @param string|callable $wrapper + * + * @return HtmlDocument + */ + public static function wrapEach($list, $wrapper) + { + if (! is_iterable($list)) { + throw new InvalidArgumentException(sprintf( + 'Html::wrapEach() requires a traversable list, got "%s"', + get_php_type($list) + )); + } + $result = new HtmlDocument(); + foreach ($list as $name => $value) { + if (is_string($wrapper)) { + $result->addHtml(Html::tag($wrapper, $value)); + } elseif (is_callable($wrapper)) { + $result->add($wrapper($name, $value)); + } else { + throw new InvalidArgumentException(sprintf( + 'Wrapper must be callable or a string in Html::wrapEach(), got "%s"', + get_php_type($wrapper) + )); + } + } + + return $result; + } + + /** + * Ensure that the given content of mixed type is converted to an instance of {@link ValidHtml} + * + * Returns the very same element in case it's already an instance of {@link ValidHtml}. + * + * @param mixed $any + * + * @return ValidHtml + * + * @throws InvalidArgumentException In case the given content is of an unsupported type + */ + public static function wantHtml($any) + { + if ($any instanceof ValidHtml) { + return $any; + } elseif (static::canBeRenderedAsString($any)) { + return new Text($any); + } elseif (is_iterable($any)) { + $html = new HtmlDocument(); + foreach ($any as $el) { + if ($el !== null) { + $html->addHtml(static::wantHtml($el)); + } + } + + return $html; + } else { + throw new InvalidArgumentException(sprintf( + 'String, Html Element or Array of such expected, got "%s"', + get_php_type($any) + )); + } + } + + /** + * Accept any input and return it as list of ValidHtml + * + * @param mixed $content + * + * @return ValidHtml[] + */ + public static function wantHtmlList($content) + { + $list = []; + + if ($content === null) { + return $list; + } elseif (! is_iterable($content)) { + $list[] = static::wantHtml($content); + } elseif ($content instanceof ValidHtml) { + $list[] = $content; + } else { + foreach ($content as $part) { + $list = array_merge($list, static::wantHtmlList($part)); + } + } + + return $list; + } + + /** + * Get whether the given variable be rendered as a string + * + * @param mixed $any + * + * @return bool + */ + public static function canBeRenderedAsString($any) + { + return is_scalar($any) || is_null($any) || ( + is_object($any) && method_exists($any, '__toString') + ); + } + + /** + * Forward inaccessible static method calls to {@link Html::tag()} with the method's name as tag + * + * @param string $name + * @param array $arguments + * + * @return HtmlElement + */ + public static function __callStatic($name, $arguments) + { + $attributes = array_shift($arguments); + $content = array_shift($arguments); + + return static::tag($name, $attributes, $content); + } + + /** + * @deprecated Use {@link Html::encode()} instead + */ + public static function escapeForHtml($content) + { + return static::escape($content); + } + + /** + * @deprecated Use {@link Error::render()} instead + */ + public static function renderError($error) + { + return Error::render($error); + } +} diff --git a/vendor/ipl/html/src/HtmlDocument.php b/vendor/ipl/html/src/HtmlDocument.php new file mode 100644 index 0000000..e4e977e --- /dev/null +++ b/vendor/ipl/html/src/HtmlDocument.php @@ -0,0 +1,607 @@ +<?php + +namespace ipl\Html; + +use Countable; +use Exception; +use InvalidArgumentException; +use ipl\Html\Contract\Wrappable; +use ipl\Stdlib\Events; +use RuntimeException; + +/** + * HTML document + * + * An HTML document is composed of a tree of HTML nodes, i.e. text nodes and HTML elements. + */ +class HtmlDocument implements Countable, Wrappable +{ + use Events; + + /** @var string Emitted after the content has been assembled */ + public const ON_ASSEMBLED = 'assembled'; + + /** @var string Content separator */ + protected $contentSeparator = ''; + + /** @var bool Whether the document has been assembled */ + protected $hasBeenAssembled = false; + + /** @var Wrappable Wrapper */ + protected $wrapper; + + /** @var Wrappable Wrapped element */ + private $wrapped; + + /** @var HtmlDocument The currently responsible wrapper */ + private $renderedBy; + + /** @var ValidHtml[] Content */ + private $content = []; + + /** @var array */ + private $contentIndex = []; + + /** + * Set the element to wrap + * + * @param Wrappable $element + * + * @return $this + */ + private function setWrapped(Wrappable $element) + { + $this->wrapped = $element; + + return $this; + } + + /** + * Consume the wrapped element + * + * @return Wrappable + */ + private function consumeWrapped() + { + $wrapped = $this->wrapped; + $this->wrapped = null; + + return $wrapped; + } + + /** + * Get the content + * + * return ValidHtml[] + */ + public function getContent() + { + return $this->content; + } + + /** + * Set the content + * + * @param mixed $content + * + * @return $this + */ + public function setContent($content) + { + $this->content = []; + $this->setHtmlContent(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Set content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function setHtmlContent(ValidHtml ...$content) + { + $this->content = []; + foreach ($content as $element) { + $this->addIndexedContent($element); + } + + return $this; + } + + /** + * Get the content separator + * + * @return string + */ + public function getSeparator() + { + return $this->contentSeparator; + } + + /** + * Set the content separator + * + * @param string $separator + * + * @return $this + */ + public function setSeparator($separator) + { + $this->contentSeparator = $separator; + + return $this; + } + + /** + * Get the first {@link BaseHtmlElement} with the given tag + * + * @param string $tag + * + * @return BaseHtmlElement + * + * @throws InvalidArgumentException If no {@link BaseHtmlElement} with the given tag exists + */ + public function getFirst($tag) + { + foreach ($this->content as $c) { + if ($c instanceof BaseHtmlElement && $c->getTag() === $tag) { + return $c; + } + } + + throw new InvalidArgumentException(sprintf( + 'Trying to get first %s, but there is no such', + $tag + )); + } + + /** + * Insert Html after an existing Html node + * + * @param ValidHtml $newNode + * @param ValidHtml $existingNode + * + * @return $this + */ + public function insertAfter(ValidHtml $newNode, ValidHtml $existingNode): self + { + $index = array_search($existingNode, $this->content, true); + if ($index === false) { + throw new InvalidArgumentException('The content does not contain the $existingNode'); + } + + array_splice($this->content, (int) $index + 1, 0, [$newNode]); + + $this->reIndexContent(); + + return $this; + } + + /** + * Insert Html after an existing Html node + * + * @param ValidHtml $newNode + * @param ValidHtml $existingNode + * + * @return $this + */ + public function insertBefore(ValidHtml $newNode, ValidHtml $existingNode): self + { + $index = array_search($existingNode, $this->content); + if ($index === false) { + throw new InvalidArgumentException('The content does not contain the $existingNode'); + } + + array_splice($this->content, (int) $index, 0, [$newNode]); + + $this->reIndexContent(); + + return $this; + } + + /** + * Add content + * + * @param mixed $content + * + * @return $this + */ + public function add($content) + { + $this->addHtml(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Add content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function addHtml(ValidHtml ...$content) + { + foreach ($content as $element) { + $this->addIndexedContent($element); + } + + return $this; + } + + /** + * Add content from the given document + * + * @param HtmlDocument $from + * @param callable $callback Optional callback in order to transform the content to add + * + * @return $this + */ + public function addFrom(HtmlDocument $from, $callback = null) + { + $from->ensureAssembled(); + + $isCallable = is_callable($callback); + foreach ($from->getContent() as $item) { + $this->add($isCallable ? $callback($item) : $item); + } + + return $this; + } + + /** + * Check whether the given element is a direct or indirect child of this document + * + * A direct child is one that is part of this document's content. An indirect child + * is one that is part of a direct child's content (recursively). + * + * @param ValidHtml $element + * + * @return bool + */ + public function contains(ValidHtml $element) + { + $key = spl_object_hash($element); + if (array_key_exists($key, $this->contentIndex)) { + return true; + } + + foreach ($this->content as $child) { + if ($child instanceof self && $child->contains($element)) { + return true; + } + } + + return false; + } + + /** + * Prepend content + * + * @param mixed $content + * + * @return $this + */ + public function prepend($content) + { + $this->prependHtml(...Html::wantHtmlList($content)); + + return $this; + } + + /** + * Prepend content + * + * @param ValidHtml ...$content + * + * @return $this + */ + public function prependHtml(ValidHtml ...$content) + { + foreach (array_reverse($content) as $html) { + array_unshift($this->content, $html); + $this->incrementIndexKeys(); + $this->addObjectPosition($html, 0); + } + + return $this; + } + + /** + * Remove content + * + * @param ValidHtml $html + * + * @return $this + */ + public function remove(ValidHtml $html) + { + $key = spl_object_hash($html); + if (array_key_exists($key, $this->contentIndex)) { + foreach ($this->contentIndex[$key] as $pos) { + unset($this->content[$pos]); + } + } + $this->content = array_values($this->content); + + $this->reIndexContent(); + + return $this; + } + + /** + * Ensure that the document has been assembled + * + * @return $this + */ + public function ensureAssembled() + { + if (! $this->hasBeenAssembled) { + $this->hasBeenAssembled = true; + $this->assemble(); + + $this->emit(static::ON_ASSEMBLED, [$this]); + } + + return $this; + } + + /** + * Get whether the document is empty + * + * @return bool + */ + public function isEmpty() + { + $this->ensureAssembled(); + + return empty($this->content); + } + + /** + * Render the content to HTML but ignore any wrapper + * + * @return string + */ + public function renderUnwrapped() + { + $this->ensureAssembled(); + $html = []; + + // This **must** be consumed after the document's assembly but before rendering the content. + // If the document consumes it during assembly, nothing happens. If the document is used as + // wrapper for another element, consuming it asap prevents a left-over reference and avoids + // the element from getting rendered multiple times. + $wrapped = $this->consumeWrapped(); + + $content = $this->getContent(); + if ($wrapped !== null && ! $this->contains($wrapped) && ! $this->isIntermediateWrapper($wrapped)) { + $content[] = $wrapped; + } + + foreach ($content as $element) { + if ($element instanceof self) { + $element->renderedBy = $this; + } + + $html[] = $element->render(); + + if ($element instanceof self) { + $element->renderedBy = null; + } + } + + return implode($this->contentSeparator, $html); + } + + public function __clone() + { + foreach ($this->content as $key => $element) { + $this->content[$key] = clone $element; + } + + $this->reIndexContent(); + } + + /** + * Render content to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + /** + * Assemble the document + * + * Override this method in order to provide content in concrete classes. + */ + protected function assemble() + { + } + + /** + * Render the document to HTML respecting the set wrapper + * + * @return string + */ + protected function renderWrapped() + { + $wrapper = $this->wrapper; + + if (isset($this->renderedBy)) { + if ($wrapper === $this->renderedBy || $wrapper->contains($this->renderedBy)) { + // $this might be an intermediate wrapper that's already about to be rendered. + // In case of an element (referencing $this as a wrapper) that is a child of an + // outer wrapper, it is required to ignore $wrapper as otherwise it's a loop. + // ($wrapper then is in the render path of the outer wrapper and sideways "stolen") + return $this->renderUnwrapped(); + } + + $wrapper->renderedBy = $this->renderedBy; + } elseif (isset($wrapper->renderedBy)) { + throw new RuntimeException('Wrapper loop detected'); + } else { + $this->renderedBy = $wrapper; + } + + $html = $wrapper->renderWrappedDocument($this); + + if (isset($this->renderedBy)) { + if ($this->renderedBy === $wrapper) { + $this->renderedBy = null; + } elseif ($wrapper->renderedBy === $this->renderedBy) { + $wrapper->renderedBy = null; + } + } + + return $html; + } + + /** + * Render the given document to HTML by treating this document as the wrapper + * + * @param HtmlDocument $document + * + * @return string + */ + protected function renderWrappedDocument(HtmlDocument $document) + { + return $this->setWrapped($document)->render(); + } + + #[\ReturnTypeWillChange] + public function count() + { + return count($this->content); + } + + public function getWrapper() + { + return $this->wrapper; + } + + public function setWrapper(Wrappable $wrapper) + { + $this->wrapper = $wrapper; + + return $this; + } + + public function addWrapper(Wrappable $wrapper) + { + if ($this->wrapper === null) { + $this->setWrapper($wrapper); + } else { + $this->wrapper->addWrapper($wrapper); + } + + return $this; + } + + public function prependWrapper(Wrappable $wrapper) + { + if ($this->wrapper === null) { + $this->setWrapper($wrapper); + } else { + $wrapper->addWrapper($this->wrapper); + $this->setWrapper($wrapper); + } + + return $this; + } + + /** + * Check whether the given element wraps this document (recursively) + * + * @param ValidHtml $element + * + * @return bool + */ + protected function wrappedBy(ValidHtml $element) + { + if ($this->wrapper === null) { + return false; + } + + if ($this->wrapper === $element || $this->wrapper->wrappedBy($element)) { + return true; + } + + return false; + } + + /** + * Get whether the given element is an intermediate wrapper + * + * @param ValidHtml $element + * + * @return bool + */ + protected function isIntermediateWrapper(ValidHtml $element): bool + { + foreach ($this->content as $child) { + if ($child instanceof self && $child->wrappedBy($element)) { + return true; + } + } + + return false; + } + + public function render() + { + $this->ensureAssembled(); + if ($this->wrapper === null) { + return $this->renderUnwrapped(); + } else { + return $this->renderWrapped(); + } + } + + private function addIndexedContent(ValidHtml $html) + { + $pos = count($this->content); + $this->content[$pos] = $html; + $this->addObjectPosition($html, $pos); + } + + private function addObjectPosition(ValidHtml $html, $pos) + { + $key = spl_object_hash($html); + if (array_key_exists($key, $this->contentIndex)) { + $this->contentIndex[$key][] = $pos; + } else { + $this->contentIndex[$key] = [$pos]; + } + } + + private function incrementIndexKeys() + { + foreach ($this->contentIndex as & $index) { + foreach ($index as & $pos) { + $pos++; + } + } + } + + private function reIndexContent() + { + $this->contentIndex = []; + foreach ($this->content as $pos => $html) { + $this->addObjectPosition($html, $pos); + } + } +} diff --git a/vendor/ipl/html/src/HtmlElement.php b/vendor/ipl/html/src/HtmlElement.php new file mode 100644 index 0000000..4f5d162 --- /dev/null +++ b/vendor/ipl/html/src/HtmlElement.php @@ -0,0 +1,43 @@ +<?php + +namespace ipl\Html; + +/** + * The HtmlElement represents any HTML element + * + * A typical HTML element includes a tag, attributes and content. + */ +class HtmlElement extends BaseHtmlElement +{ + /** + * Create a new HTML element from the given tag, attributes and content + * + * @param string $tag The tag for the element + * @param Attributes $attributes The HTML attributes for the element + * @param ValidHtml ...$content The content of the element + */ + public function __construct($tag, Attributes $attributes = null, ValidHtml ...$content) + { + $this->tag = $tag; + + if ($attributes !== null) { + $this->getAttributes()->merge($attributes); + } + + $this->setHtmlContent(...$content); + } + + /** + * Create a new HTML element from the given tag, attributes and content + * + * @param string $tag The tag for the element + * @param mixed $attributes The HTML attributes for the element + * @param mixed $content The content of the element + * + * @return static + */ + public static function create($tag, $attributes = null, $content = null) + { + return new static($tag, Attributes::wantAttributes($attributes), ...Html::wantHtmlList($content)); + } +} diff --git a/vendor/ipl/html/src/HtmlString.php b/vendor/ipl/html/src/HtmlString.php new file mode 100644 index 0000000..e62086b --- /dev/null +++ b/vendor/ipl/html/src/HtmlString.php @@ -0,0 +1,13 @@ +<?php + +namespace ipl\Html; + +/** + * HTML string + * + * HTML strings promise to be already escaped and can be anything from simple text to full HTML markup. + */ +class HtmlString extends Text +{ + protected $escaped = true; +} diff --git a/vendor/ipl/html/src/Table.php b/vendor/ipl/html/src/Table.php new file mode 100644 index 0000000..28a6738 --- /dev/null +++ b/vendor/ipl/html/src/Table.php @@ -0,0 +1,226 @@ +<?php + +namespace ipl\Html; + +use RuntimeException; +use stdClass; + +class Table extends BaseHtmlElement +{ + protected $contentSeparator = "\n"; + + /** @var string */ + protected $tag = 'table'; + + /** @var HtmlElement */ + private $caption; + + /** @var HtmlElement */ + private $header; + + /** @var HtmlElement */ + private $body; + + /** @var HtmlElement */ + private $footer; + + public function addHtml(ValidHtml ...$content) + { + foreach ($content as $html) { + if ($html instanceof BaseHtmlElement) { + switch ($html->getTag()) { + case 'tr': + $this->getBody()->addHtml($html); + + break; + case 'thead': + parent::addHtml($html); + $this->header = $html; + + break; + case 'tbody': + parent::addHtml($html); + $this->body = $html; + + break; + case 'tfoot': + parent::addHtml($html); + $this->footer = $html; + + break; + case 'caption': + if ($this->caption === null) { + $this->prependHtml($html); + $this->caption = $html; + } else { + throw new RuntimeException( + 'Tables allow only one <caption> tag' + ); + } + + break; + default: + $this->getBody()->addHtml(static::row([$html])); + } + } else { + $this->getBody()->addHtml(static::row([$html])); + } + } + + return $this; + } + + /** + * @param mixed $content + * @return $this + */ + public function add($content) + { + if ($content instanceof stdClass) { + $this->getBody()->addHtml(static::row((array) $content)); + } elseif (is_iterable($content)) { + $this->getBody()->addHtml(static::row($content)); + } elseif ($content instanceof ValidHtml) { + $this->addHtml($content); + } else { + $this->getBody()->addHtml(static::row([$content])); + } + + return $this; + } + + /** + * Set the table title + * + * Will be rendered as a "caption" HTML element + * + * @param mixed $caption + * @return $this + */ + public function setCaption($caption) + { + if ($caption instanceof BaseHtmlElement && $caption->getTag() === 'caption') { + $this->caption = $caption; + $this->prependHtml($caption); + } elseif ($this->caption === null) { + $this->caption = new HtmlElement('caption', null, ...Html::wantHtmlList($caption)); + $this->prependHtml($this->caption); + } else { + $this->caption->setContent($caption); + } + + return $this; + } + + /** + * Static helper creating a tr element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function tr($content = null, $attributes = null) + { + return Html::tag('tr', $attributes, $content); + } + + /** + * Static helper creating a th element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function th($content = null, $attributes = null) + { + return Html::tag('th', $attributes, $content); + } + + /** + * Static helper creating a td element + * + * @param Attributes|array $attributes + * @param Html|array|string $content + * @return HtmlElement + */ + public static function td($content = null, $attributes = null) + { + return Html::tag('td', $attributes, $content); + } + + /** + * @param $row + * @param null $attributes + * @param string $tag + * @return HtmlElement + */ + public static function row($row, $attributes = null, $tag = 'td') + { + $tr = static::tr(); + foreach ((array) $row as $value) { + $tr->addHtml(Html::tag($tag, null, $value)); + } + + if ($attributes !== null) { + $tr->setAttributes($attributes); + } + + return $tr; + } + + /** + * @return HtmlElement + */ + public function getBody() + { + if ($this->body === null) { + $this->addHtml(Html::tag('tbody')->setSeparator("\n")); + } + + return $this->body; + } + + /** + * @return HtmlElement + */ + public function getHeader() + { + if ($this->header === null) { + $this->addHtml(Html::tag('thead')->setSeparator("\n")); + } + + return $this->header; + } + + /** + * @return HtmlElement + */ + public function getFooter() + { + if ($this->footer === null) { + $this->addHtml(Html::tag('tfoot')->setSeparator("\n")); + } + + return $this->footer; + } + + /** + * @return HtmlElement + */ + public function nextBody() + { + $this->body = null; + + return $this->getBody(); + } + + /** + * @return HtmlElement + */ + public function nextHeader() + { + $this->header = null; + + return $this->getHeader(); + } +} diff --git a/vendor/ipl/html/src/TemplateString.php b/vendor/ipl/html/src/TemplateString.php new file mode 100644 index 0000000..b1e01c0 --- /dev/null +++ b/vendor/ipl/html/src/TemplateString.php @@ -0,0 +1,175 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * Render {{#mustache}}Mustache{{/mustache}}-like string from {@link ValidHtml} element arguments + * + * # Example Usage + * ``` + * $info = TemplateString::create( + * 'Follow the {{#doc}}HTML documentation{{/doc}} for more information on {{#strong}}HTML elements{{/strong}}', + * [ + * 'doc' => new Link(null, 'doc/html'), + * 'strong' => Html::tag('strong') + * ] + * ); + * ``` + */ +class TemplateString extends FormattedString +{ + /** @var array */ + protected $templateArgs = []; + + /** @var int */ + protected $pos = 0; + + /** @var string */ + protected $string; + + /** @var int */ + protected $length; + + public function __construct($format, $args = null) + { + $parentArgs = []; + foreach ($args ?: [] as $val) { + if (is_array($val) && is_string(key($val))) { + $this->templateArgs += $val; + } else { + $parentArgs[] = $val; + } + } + + parent::__construct($format, $parentArgs); + } + + /** + * Parse template strings + * + * @param ?string $for template name + * @return HtmlDocument + * @throws Exception in case of missing template argument or unbounded open or close templates + */ + protected function parseTemplates($for = null) + { + $buffer = ''; + + while (($char = $this->readChar()) !== false) { + if ($char !== '{') { + $buffer .= $char; + continue; + } + + $nextChar = $this->readChar(); + if ($nextChar !== '{') { + $buffer .= $char . $nextChar; + continue; + } + + $templateHandle = $this->readChar(); + $start = $templateHandle === '#'; + $end = $templateHandle === '/'; + + $templateKey = $this->readUntil('}'); + // if the string following '{{#' is read up to the last character or (length - 1)th character + // then it is not a template + if ($this->pos >= $this->length - 1) { + $buffer .= $char . $nextChar . $templateHandle . $templateKey; + continue; + } + + $this->pos++; + $closeChar = $this->readChar(); + + if ($closeChar !== '}') { + $buffer .= $char . $nextChar . $templateHandle . $templateKey . '}' . $closeChar; + continue; + } + + if ($start) { + if (isset($this->templateArgs[$templateKey])) { + $wrapper = $this->templateArgs[$templateKey]; + + $buffer .= $this->parseTemplates($templateKey)->prependWrapper($wrapper); + } else { + throw new Exception(sprintf( + 'Missing template argument: %s ', + $templateKey + )); + } + } elseif ($for === $templateKey && $end) { + // close the template + $for = null; + break; + } else { + // throw exception for unbounded closing of templates + throw new Exception(sprintf( + 'Unbound closing of template: %s', + $templateKey + )); + } + } + + if ($this->pos === $this->length && $for !== null) { + throw new Exception(sprintf( + 'Unbound opening of template: %s', + $for + )); + } + + return (new HtmlDocument())->addHtml(HtmlString::create($buffer)); + } + + /** + * Read until any of the given chars appears + * + * @param string ...$chars + * + * @return string + */ + protected function readUntil(...$chars) + { + $buffer = ''; + while (($c = $this->readChar()) !== false) { + if (in_array($c, $chars, true)) { + $this->pos--; + break; + } + + $buffer .= $c; + } + + return $buffer; + } + + /** + * Read a single character + * + * @return false|string false if there is no character left + */ + protected function readChar() + { + if ($this->length > $this->pos) { + return $this->string[$this->pos++]; + } + + return false; + } + + public function render() + { + $formattedstring = parent::render(); + if (empty($this->templateArgs)) { + return $formattedstring; + } + + $this->string = $formattedstring; + + $this->length = strlen($formattedstring); + + return $this->parseTemplates()->render(); + } +} diff --git a/vendor/ipl/html/src/Text.php b/vendor/ipl/html/src/Text.php new file mode 100644 index 0000000..710e3d9 --- /dev/null +++ b/vendor/ipl/html/src/Text.php @@ -0,0 +1,116 @@ +<?php + +namespace ipl\Html; + +use Exception; + +/** + * A text node + * + * Primitive element that renders text to HTML while automatically escaping its content. + * If the passed content is already escaped, see {@link setEscaped()} to indicate this. + */ +class Text implements ValidHtml +{ + /** @var string */ + protected $content; + + /** @var bool Whether the content is already escaped */ + protected $escaped = false; + + /** + * Create a new text node + * + * @param string $content + */ + public function __construct($content) + { + $this->setContent($content); + } + + /** + * Create a new text node + * + * @param string $content + * + * @return static + */ + public static function create($content) + { + return new static($content); + } + + /** + * Get the content + * + * @return string + */ + public function getContent() + { + return $this->content; + } + + /** + * Set the content + * + * @param string $content + * + * @return $this + */ + public function setContent($content) + { + $this->content = (string) $content; + + return $this; + } + + /** + * Get whether the content promises to be already escaped + * + * @return bool + */ + public function isEscaped() + { + return $this->escaped; + } + + /** + * Set whether the content is already escaped + * + * @param bool $escaped + * + * @return $this + */ + public function setEscaped($escaped = true) + { + $this->escaped = $escaped; + + return $this; + } + + /** + * Render text to HTML when treated like a string + * + * Calls {@link render()} internally in order to render the text to HTML. + * Exceptions will be automatically caught and returned as HTML string as well using {@link Error::render()}. + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return Error::render($e); + } + } + + public function render() + { + if ($this->escaped) { + return $this->content; + } else { + return Html::escape($this->content); + } + } +} diff --git a/vendor/ipl/html/src/ValidHtml.php b/vendor/ipl/html/src/ValidHtml.php new file mode 100644 index 0000000..2b88af4 --- /dev/null +++ b/vendor/ipl/html/src/ValidHtml.php @@ -0,0 +1,17 @@ +<?php + +namespace ipl\Html; + +/** + * Interface for HTML elements or primitives that promise to render valid UTF-8 encoded HTML5 with special characters + * converted to HTML entities + */ +interface ValidHtml +{ + /** + * Render to HTML + * + * @return string UTF-8 encoded HTML5 with special characters converted to HTML entities + */ + public function render(); +} |