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); } } }