summaryrefslogtreecommitdiffstats
path: root/vendor/ipl/html/src/HtmlDocument.php
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/ipl/html/src/HtmlDocument.php')
-rw-r--r--vendor/ipl/html/src/HtmlDocument.php607
1 files changed, 607 insertions, 0 deletions
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);
+ }
+ }
+}