src/View/Helper/Navigation/Links.php
<?php
/**
* This file is part of the mimmi20/mezzio-navigation-laminasviewrenderer package.
*
* Copyright (c) 2020-2024, Thomas Mueller <mimmi20@live.de>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types = 1);
namespace Mimmi20\Mezzio\Navigation\LaminasView\View\Helper\Navigation;
use Laminas\View\Exception;
use Laminas\View\Helper\HeadLink;
use Mimmi20\Mezzio\Navigation\ContainerInterface;
use Mimmi20\Mezzio\Navigation\Exception\InvalidArgumentException;
use Mimmi20\Mezzio\Navigation\Page\PageInterface;
use Mimmi20\NavigationHelper\ContainerParser\ContainerParserInterface;
use Mimmi20\NavigationHelper\ConvertToPages\ConvertToPagesInterface;
use Mimmi20\NavigationHelper\FindRoot\FindRootInterface;
use Mimmi20\NavigationHelper\Htmlify\HtmlifyInterface;
use Override;
use RecursiveIteratorIterator;
use function array_diff;
use function array_filter;
use function array_key_exists;
use function array_keys;
use function array_merge;
use function array_search;
use function array_values;
use function assert;
use function get_debug_type;
use function in_array;
use function is_array;
use function is_iterable;
use function is_string;
use function mb_strlen;
use function mb_strtolower;
use function method_exists;
use function preg_match;
use function rtrim;
use function sprintf;
use function ucfirst;
use const PHP_EOL;
/**
* Helper for printing <link> elements
*/
final class Links extends AbstractHelper implements LinksInterface
{
/**
* The helper's render flag
*
* @see render()
* @see setRenderFlag()
*/
private int $renderFlag = LinksInterface::RENDER_ALL;
/** @throws void */
public function __construct(
HtmlifyInterface $htmlify,
ContainerParserInterface $containerParser,
private readonly ConvertToPagesInterface $convertToPages,
private readonly FindRootInterface $rootFinder,
private readonly HeadLink $headLink,
) {
parent::__construct($htmlify, $containerParser);
}
/**
* Magic overload: Proxy calls to {@link findRelation()} or container
*
* Examples of finder calls:
* <code>
* // METHOD // SAME AS
* $h->findRelNext($page); // $h->findRelation($page, 'rel', 'next')
* $h->findRevSection($page); // $h->findRelation($page, 'rev', 'section');
* $h->findRelFoo($page); // $h->findRelation($page, 'rel', 'foo');
* </code>
*
* @param array<int, mixed> $arguments
*
* @throws Exception\DomainException
* @throws Exception\InvalidArgumentException
* @throws Exception\RuntimeException
*/
#[Override]
public function __call(string $method, array $arguments = []): mixed
{
$result = preg_match('/find(Rel|Rev)(.+)/', $method, $match);
if ($result && $arguments[0] instanceof PageInterface) {
$rel = mb_strtolower($match[1]);
$type = mb_strtolower($match[2]);
assert($rel === 'rel' || $rel === 'rev');
return $this->findRelation(page: $arguments[0], rel: $rel, type: $type);
}
return parent::__call($method, $arguments);
}
/**
* Renders helper
*
* Implements {@link ViewHelperInterface::render()}.
*
* @param ContainerInterface<PageInterface>|string|null $container [optional] container to render. Default is null, which indicates that the helper should render the container returned by {@link getContainer()}.
*
* @throws Exception\DomainException
* @throws Exception\RuntimeException
* @throws Exception\InvalidArgumentException
*/
#[Override]
public function render(ContainerInterface | string | null $container = null): string
{
try {
$container = $this->containerParser->parseContainer($container);
} catch (\Laminas\Stdlib\Exception\InvalidArgumentException $e) {
throw new Exception\InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
}
if ($container === null) {
$container = $this->getContainer();
}
$active = $this->findActive($container);
if (!array_key_exists('page', $active) || !$active['page'] instanceof PageInterface) {
// no active page
return '';
}
$active = $active['page'];
$output = '';
$indent = $this->getIndent();
$this->rootFinder->setRoot($container);
$result = $this->findAllRelations($active, $this->getRenderFlag());
foreach ($result as $attrib => $types) {
foreach ($types as $relation => $pages) {
foreach ($pages as $page) {
$r = $this->renderLink($page, $attrib, $relation);
if ($r === '') {
continue;
}
$output .= $indent . $r . PHP_EOL;
}
}
}
$this->rootFinder->setRoot(null);
// return output (trim last newline by spec)
return mb_strlen($output) ? rtrim($output, PHP_EOL) : '';
}
/**
* Renders the given $page as a link element, with $attrib = $relation
*
* @param PageInterface $page the page to render the link for
* @param 'rel'|'rev' $attrib the attribute to use for $type, either 'rel' or 'rev'
* @param string $relation relation type, muse be one of;
* alternate, appendix, bookmark, chapter, contents, copyright,
* glossary, help, home, index, next, prev, section, start, stylesheet,
* subsection
* @phpstan-param value-of<LinksInterface::RELATIONS> $relation
*
* @throws Exception\DomainException
*/
#[Override]
public function renderLink(PageInterface $page, string $attrib, string $relation): string
{
if (!in_array($attrib, ['rel', 'rev'], true)) {
throw new Exception\DomainException(
sprintf(
'Invalid relation attribute "%s", must be "rel" or "rev"',
$attrib,
),
);
}
$href = $page->getHref();
if (!$href) {
return '';
}
// TODO: add more attribs
// http://www.w3.org/TR/html401/struct/links.html#h-12.2
$attribs = [
$attrib => $relation,
'href' => $href,
'title' => $page->getLabel(),
];
$otherAttributes = ['type', 'hreflang', 'charset', 'lang', 'media'];
foreach ($otherAttributes as $otherAttributeName) {
try {
$otherAttributeValue = $page->get($otherAttributeName);
} catch (InvalidArgumentException) {
continue;
}
if ($otherAttributeValue === null) {
continue;
}
$attribs[$otherAttributeName] = $otherAttributeValue;
}
return $this->headLink->itemToString((object) $attribs);
}
// Finder methods:
/**
* Finds all relations (forward and reverse) for the given $page
*
* The form of the returned array:
* <code>
* // $page denotes an instance of Mimmi20\Mezzio\Navigation\Page\PageInterface
* $returned = array(
* 'rel' => array(
* 'alternate' => array($page, $page, $page),
* 'start' => array($page),
* 'next' => array($page),
* 'prev' => array($page),
* 'canonical' => array($page)
* ),
* 'rev' => array(
* 'section' => array($page)
* )
* );
* </code>
*
* @param PageInterface $page page to find links for
*
* @return array<string, array<int|string, array<int, PageInterface>>>
* @phpstan-return array<'rel'|'rev', array<string, non-empty-array<int, PageInterface>>>
*
* @throws void
*/
#[Override]
public function findAllRelations(PageInterface $page, int | null $flag = null): array
{
if ($flag === null) {
$flag = self::RENDER_ALL;
}
$result = ['rel' => [], 'rev' => []];
$native = array_values(LinksInterface::RELATIONS);
foreach (array_keys($result) as $rel) {
$meth = 'getDefined' . ucfirst($rel);
$types = array_merge($native, array_diff($page->{$meth}(), $native));
foreach ($types as $type) {
if (!is_string($type)) {
continue;
}
$relFlag = array_search($type, LinksInterface::RELATIONS, true);
if (!$relFlag) {
$relFlag = self::RENDER_CUSTOM;
}
if (!($flag & (int) $relFlag)) {
continue;
}
try {
$found = $this->findRelation($page, $rel, $type);
} catch (Exception\DomainException | Exception\RuntimeException | Exception\InvalidArgumentException) {
continue;
}
if (!$found) {
continue;
}
$result[$rel][$type] = $found;
}
}
return $result;
}
/**
* Finds relations of the given $rel=$type from $page
*
* This method will first look for relations in the page instance, then
* by searching the root container if nothing was found in the page.
*
* @param PageInterface $page page to find relations for
* @param 'rel'|'rev' $rel relation, "rel" or "rev"
* @param string $type link type, e.g. 'start', 'next'
* @phpstan-param value-of<LinksInterface::RELATIONS> $type
*
* @return array<int, PageInterface>
*
* @throws Exception\DomainException
* @throws Exception\InvalidArgumentException
* @throws Exception\RuntimeException
*/
#[Override]
public function findRelation(PageInterface $page, string $rel, string $type): array
{
if (!in_array($rel, ['rel', 'rev'], true)) {
throw new Exception\DomainException(
sprintf(
'Invalid argument: $rel must be "rel" or "rev"; "%s" given',
$rel,
),
);
}
$result = $this->findFromProperty($page, $rel, $type);
if (!$result) {
$result = $this->findFromSearch($page, $rel, $type);
if ($result === null) {
return [];
}
if (!is_array($result)) {
$result = [$result];
}
}
return $result;
}
// Search methods:
/**
* Searches the root container for the forward 'start' relation of the given
* $page
*
* From {@link http://www.w3.org/TR/html4/types.html#type-links}:
* Refers to the first document in a collection of documents. This link type
* tells search engines which document is considered by the author to be the
* starting point of the collection.
*
* @throws Exception\RuntimeException
*/
#[Override]
public function searchRelStart(PageInterface $page): PageInterface | null
{
$found = $this->rootFinder->find($page);
if (!$found instanceof PageInterface) {
$found->rewind();
$found = $found->current();
}
if ($found === $page || !$this->accept($found)) {
$found = null;
}
return $found;
}
/**
* Searches the root container for the forward 'next' relation of the given
* $page
*
* From {@link http://www.w3.org/TR/html4/types.html#type-links}:
* Refers to the next document in a linear sequence of documents. User
* agents may choose to preload the "next" document, to reduce the perceived
* load time.
*
* @throws Exception\RuntimeException
*/
#[Override]
public function searchRelNext(PageInterface $page): PageInterface | null
{
$found = null;
$break = false;
/** @var RecursiveIteratorIterator<PageInterface> $iterator */
$iterator = new RecursiveIteratorIterator(
$this->rootFinder->find($page),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $intermediate) {
assert($intermediate instanceof PageInterface);
if ($intermediate === $page) {
// current page; break at next accepted page
$break = true;
continue;
}
if ($break && $this->accept($intermediate)) {
$found = $intermediate;
break;
}
}
return $found;
}
/**
* Searches the root container for the forward 'prev' relation of the given
* $page
*
* From {@link http://www.w3.org/TR/html4/types.html#type-links}:
* Refers to the previous document in an ordered series of documents. Some
* user agents also support the synonym "Previous".
*
* @throws Exception\RuntimeException
*/
#[Override]
public function searchRelPrev(PageInterface $page): PageInterface | null
{
$found = null;
$prev = null;
/** @var RecursiveIteratorIterator<PageInterface> $iterator */
$iterator = new RecursiveIteratorIterator(
$this->rootFinder->find($page),
RecursiveIteratorIterator::SELF_FIRST,
);
foreach ($iterator as $intermediate) {
assert($intermediate instanceof PageInterface);
if (!$this->accept($intermediate)) {
continue;
}
if ($intermediate === $page) {
$found = $prev;
break;
}
$prev = $intermediate;
}
return $found;
}
/**
* Searches the root container for forward 'chapter' relations of the given
* $page
*
* From {@link http://www.w3.org/TR/html4/types.html#type-links}:
* Refers to a document serving as a chapter in a collection of documents.
*
* @return array<int, PageInterface>
*
* @throws Exception\DomainException
* @throws Exception\InvalidArgumentException
* @throws Exception\RuntimeException
*/
#[Override]
public function searchRelChapter(PageInterface $page): array
{
$found = [];
// find first level of pages
$root = $this->rootFinder->find($page);
// find start page(s)
$start = $this->findRelation($page, 'rel', 'start');
foreach ($root as $chapter) {
// exclude self and start page from chapters
if ($chapter === $page || in_array($chapter, $start, true) || !$this->accept($chapter)) {
continue;
}
$found[] = $chapter;
}
return $found;
}
/**
* Searches the root container for forward 'section' relations of the given
* $page
*
* From {@link http://www.w3.org/TR/html4/types.html#type-links}:
* Refers to a document serving as a section in a collection of documents.
*
* @return array<int, PageInterface>
*
* @throws Exception\RuntimeException
*/
#[Override]
public function searchRelSection(PageInterface $page): array
{
if (!$page->hasPages()) {
return [];
}
$root = $this->rootFinder->find($page);
// check if given page has pages and is a chapter page
if (!$root->hasPage($page)) {
return [];
}
$found = [];
foreach ($page as $section) {
if (!$this->accept($section)) {
continue;
}
$found[] = $section;
}
return $found;
}
/**
* Searches the root container for forward 'subsection' relations of the
* given $page
*
* From {@link http://www.w3.org/TR/html4/types.html#type-links}:
* Refers to a document serving as a subsection in a collection of
* documents.
*
* @return array<int, PageInterface>
*
* @throws Exception\RuntimeException
*/
#[Override]
public function searchRelSubsection(PageInterface $page): array
{
if (!$page->hasPages()) {
return [];
}
$root = $this->rootFinder->find($page);
$found = [];
// given page has child pages, loop chapters
foreach ($root as $chapter) {
// is page a section?
if (!$chapter->hasPage($page)) {
continue;
}
foreach ($page as $subsection) {
if (!$this->accept($subsection)) {
continue;
}
$found[] = $subsection;
}
}
return $found;
}
/**
* Searches the root container for the reverse 'section' relation of the
* given $page
*
* From {@link http://www.w3.org/TR/html4/types.html#type-links}:
* Refers to a document serving as a section in a collection of documents.
*
* @throws void
*/
#[Override]
public function searchRevSection(PageInterface $page): PageInterface | null
{
$parent = $page->getParent();
if (!$parent instanceof PageInterface) {
return null;
}
$root = $this->rootFinder->find($page);
$found = null;
if ($root->hasPage($parent)) {
$found = $parent;
}
return $found;
}
/**
* Searches the root container for the reverse 'section' relation of the
* given $page
*
* From {@link http://www.w3.org/TR/html4/types.html#type-links}:
* Refers to a document serving as a subsection in a collection of
* documents.
*
* @throws void
*/
#[Override]
public function searchRevSubsection(PageInterface $page): PageInterface | null
{
$parent = $page->getParent();
if (!$parent instanceof PageInterface) {
return null;
}
$root = $this->rootFinder->find($page);
$found = null;
foreach ($root as $chapter) {
if ($chapter->hasPage($parent)) {
$found = $parent;
break;
}
}
return $found;
}
// Util methods:
/**
* Sets the helper's render flag
*
* The helper uses the bitwise '&' operator against the hex values of the
* render constants. This means that the flag can is "bitwised" value of
* the render constants. Examples:
* <code>
* // render all links except glossary
* $flag = Links:RENDER_ALL ^ Links:RENDER_GLOSSARY;
* $helper->setRenderFlag($flag);
*
* // render only chapters and sections
* $flag = Links:RENDER_CHAPTER | Links:RENDER_SECTION;
* $helper->setRenderFlag($flag);
*
* // render only relations that are not native W3C relations
* $helper->setRenderFlag(Links:RENDER_CUSTOM);
*
* // render all relations (default)
* $helper->setRenderFlag(Links:RENDER_ALL);
* </code>
*
* Note that custom relations can also be rendered directly using the
* {@link renderLink()} method.
*
* @throws void
*/
#[Override]
public function setRenderFlag(int $renderFlag): self
{
$this->renderFlag = $renderFlag;
return $this;
}
/**
* Returns the helper's render flag
*
* @throws void
*/
#[Override]
public function getRenderFlag(): int
{
return $this->renderFlag;
}
/**
* Finds relations of given $type for $page by checking if the
* relation is specified as a property of $page
*
* @param PageInterface $page page to find relations for
* @param 'rel'|'rev' $rel relation, 'rel' or 'rev'
* @param string $type link type, e.g. 'start', 'next'
*
* @return array<int, PageInterface>
*
* @throws Exception\InvalidArgumentException
* @throws Exception\RuntimeException
* @throws Exception\DomainException
*/
private function findFromProperty(PageInterface $page, string $rel, string $type): array
{
$result = match ($rel) {
'rel' => $page->getRel($type),
'rev' => $page->getRev($type),
default => throw new Exception\DomainException(
sprintf(
'Invalid relation attribute "%s", must be "rel" or "rev"',
$rel,
),
),
};
if (!$result) {
return [];
}
assert(
is_iterable($result)
|| is_string($result),
get_debug_type($result),
);
try {
$convertedResult = $this->convertToPages->convert($result);
} catch (\Laminas\Stdlib\Exception\InvalidArgumentException $e) {
throw new Exception\InvalidArgumentException($e->getMessage(), $e->getCode(), $e);
}
if ($convertedResult === []) {
return [];
}
return array_filter(
$convertedResult,
/** @throws void */
fn (PageInterface $page): bool => $this->accept($page),
);
}
/**
* Finds relations of given $rel=$type for $page by using the helper to
* search for the relation in the root container
*
* @param PageInterface $page page to find relations for
* @param 'rel'|'rev' $rel relation, 'rel' or 'rev'
* @param string $type link type, e.g. 'start', 'next', etc
*
* @return array<int, PageInterface>|PageInterface|null
*
* @throws Exception\InvalidArgumentException
*/
private function findFromSearch(PageInterface $page, string $rel, string $type): array | PageInterface | null
{
$found = null;
$method = 'search' . ucfirst($rel) . ucfirst($type);
if (method_exists($this, $method)) {
$found = $this->{$method}($page);
}
return $found;
}
}