1<?php
2/**
3 * Zend Framework (http://framework.zend.com/)
4 *
5 * @link      http://github.com/zendframework/zf2 for the canonical source repository
6 * @copyright Copyright (c) 2005-2015 Zend Technologies USA Inc. (http://www.zend.com)
7 * @license   http://framework.zend.com/license/new-bsd New BSD License
8 */
9
10namespace Zend\View\Helper\Navigation;
11
12use RecursiveIteratorIterator;
13use Traversable;
14use Zend\Navigation\AbstractContainer;
15use Zend\Navigation\Page\AbstractPage;
16use Zend\Stdlib\ArrayUtils;
17use Zend\Stdlib\ErrorHandler;
18use Zend\View\Exception;
19
20/**
21 * Helper for printing <link> elements
22 */
23class Links extends AbstractHelper
24{
25    /**
26     * Constants used for specifying which link types to find and render
27     *
28     * @var int
29     */
30    const RENDER_ALTERNATE  = 0x0001;
31    const RENDER_STYLESHEET = 0x0002;
32    const RENDER_START      = 0x0004;
33    const RENDER_NEXT       = 0x0008;
34    const RENDER_PREV       = 0x0010;
35    const RENDER_CONTENTS   = 0x0020;
36    const RENDER_INDEX      = 0x0040;
37    const RENDER_GLOSSARY   = 0x0080;
38    const RENDER_COPYRIGHT  = 0x0100;
39    const RENDER_CHAPTER    = 0x0200;
40    const RENDER_SECTION    = 0x0400;
41    const RENDER_SUBSECTION = 0x0800;
42    const RENDER_APPENDIX   = 0x1000;
43    const RENDER_HELP       = 0x2000;
44    const RENDER_BOOKMARK   = 0x4000;
45    const RENDER_CUSTOM     = 0x8000;
46    const RENDER_ALL        = 0xffff;
47
48    /**
49     * Maps render constants to W3C link types
50     *
51     * @var array
52     */
53    protected static $RELATIONS = array(
54        self::RENDER_ALTERNATE  => 'alternate',
55        self::RENDER_STYLESHEET => 'stylesheet',
56        self::RENDER_START      => 'start',
57        self::RENDER_NEXT       => 'next',
58        self::RENDER_PREV       => 'prev',
59        self::RENDER_CONTENTS   => 'contents',
60        self::RENDER_INDEX      => 'index',
61        self::RENDER_GLOSSARY   => 'glossary',
62        self::RENDER_COPYRIGHT  => 'copyright',
63        self::RENDER_CHAPTER    => 'chapter',
64        self::RENDER_SECTION    => 'section',
65        self::RENDER_SUBSECTION => 'subsection',
66        self::RENDER_APPENDIX   => 'appendix',
67        self::RENDER_HELP       => 'help',
68        self::RENDER_BOOKMARK   => 'bookmark',
69    );
70
71    /**
72     * The helper's render flag
73     *
74     * @see render()
75     * @see setRenderFlag()
76     * @var int
77     */
78    protected $renderFlag = self::RENDER_ALL;
79
80    /**
81     * Root container
82     *
83     * Used for preventing methods to traverse above the container given to
84     * the {@link render()} method.
85     *
86     * @see _findRoot()
87     * @var AbstractContainer
88     */
89    protected $root;
90
91    /**
92     * Helper entry point
93     *
94     * @param  string|AbstractContainer $container container to operate on
95     * @return Links
96     */
97    public function __invoke($container = null)
98    {
99        if (null !== $container) {
100            $this->setContainer($container);
101        }
102
103        return $this;
104    }
105
106    /**
107     * Magic overload: Proxy calls to {@link findRelation()} or container
108     *
109     * Examples of finder calls:
110     * <code>
111     * // METHOD                  // SAME AS
112     * $h->findRelNext($page);    // $h->findRelation($page, 'rel', 'next')
113     * $h->findRevSection($page); // $h->findRelation($page, 'rev', 'section');
114     * $h->findRelFoo($page);     // $h->findRelation($page, 'rel', 'foo');
115     * </code>
116     *
117     * @param  string $method
118     * @param  array  $arguments
119     * @return mixed
120     * @throws Exception\ExceptionInterface
121     */
122    public function __call($method, array $arguments = array())
123    {
124        ErrorHandler::start(E_WARNING);
125        $result = preg_match('/find(Rel|Rev)(.+)/', $method, $match);
126        ErrorHandler::stop();
127        if ($result) {
128            return $this->findRelation($arguments[0], strtolower($match[1]), strtolower($match[2]));
129        }
130
131        return parent::__call($method, $arguments);
132    }
133
134    /**
135     * Renders helper
136     *
137     * Implements {@link HelperInterface::render()}.
138     *
139     * @param  AbstractContainer|string|null $container [optional] container to render.
140     *                                         Default is to render the
141     *                                         container registered in the
142     *                                         helper.
143     * @return string
144     */
145    public function render($container = null)
146    {
147        $this->parseContainer($container);
148        if (null === $container) {
149            $container = $this->getContainer();
150        }
151
152        $active = $this->findActive($container);
153        if ($active) {
154            $active = $active['page'];
155        } else {
156            // no active page
157            return '';
158        }
159
160        $output = '';
161        $indent = $this->getIndent();
162        $this->root = $container;
163
164        $result = $this->findAllRelations($active, $this->getRenderFlag());
165        foreach ($result as $attrib => $types) {
166            foreach ($types as $relation => $pages) {
167                foreach ($pages as $page) {
168                    $r = $this->renderLink($page, $attrib, $relation);
169                    if ($r) {
170                        $output .= $indent . $r . PHP_EOL;
171                    }
172                }
173            }
174        }
175
176        $this->root = null;
177
178        // return output (trim last newline by spec)
179        return strlen($output) ? rtrim($output, PHP_EOL) : '';
180    }
181
182    /**
183     * Renders the given $page as a link element, with $attrib = $relation
184     *
185     * @param  AbstractPage $page     the page to render the link for
186     * @param  string       $attrib   the attribute to use for $type,
187     *                                either 'rel' or 'rev'
188     * @param  string       $relation relation type, muse be one of;
189     *                                alternate, appendix, bookmark,
190     *                                chapter, contents, copyright,
191     *                                glossary, help, home, index, next,
192     *                                prev, section, start, stylesheet,
193     *                                subsection
194     * @return string
195     * @throws Exception\DomainException
196     */
197    public function renderLink(AbstractPage $page, $attrib, $relation)
198    {
199        if (!in_array($attrib, array('rel', 'rev'))) {
200            throw new Exception\DomainException(sprintf(
201                'Invalid relation attribute "%s", must be "rel" or "rev"',
202                $attrib
203            ));
204        }
205
206        if (!$href = $page->getHref()) {
207            return '';
208        }
209
210        // TODO: add more attribs
211        // http://www.w3.org/TR/html401/struct/links.html#h-12.2
212        $attribs = array(
213            $attrib  => $relation,
214            'href'   => $href,
215            'title'  => $page->getLabel()
216        );
217
218        return '<link' .
219            $this->htmlAttribs($attribs) .
220            $this->getClosingBracket();
221    }
222
223    // Finder methods:
224
225    /**
226     * Finds all relations (forward and reverse) for the given $page
227     *
228     * The form of the returned array:
229     * <code>
230     * // $page denotes an instance of Zend\Navigation\Page\AbstractPage
231     * $returned = array(
232     *     'rel' => array(
233     *         'alternate' => array($page, $page, $page),
234     *         'start'     => array($page),
235     *         'next'      => array($page),
236     *         'prev'      => array($page),
237     *         'canonical' => array($page)
238     *     ),
239     *     'rev' => array(
240     *         'section'   => array($page)
241     *     )
242     * );
243     * </code>
244     *
245     * @param  AbstractPage $page  page to find links for
246     * @param  null|int
247     * @return array
248     */
249    public function findAllRelations(AbstractPage $page, $flag = null)
250    {
251        if (!is_int($flag)) {
252            $flag = self::RENDER_ALL;
253        }
254
255        $result = array('rel' => array(), 'rev' => array());
256        $native = array_values(static::$RELATIONS);
257
258        foreach (array_keys($result) as $rel) {
259            $meth = 'getDefined' . ucfirst($rel);
260            $types = array_merge($native, array_diff($page->$meth(), $native));
261
262            foreach ($types as $type) {
263                if (!$relFlag = array_search($type, static::$RELATIONS)) {
264                    $relFlag = self::RENDER_CUSTOM;
265                }
266                if (!($flag & $relFlag)) {
267                    continue;
268                }
269
270                $found = $this->findRelation($page, $rel, $type);
271                if ($found) {
272                    if (!is_array($found)) {
273                        $found = array($found);
274                    }
275                    $result[$rel][$type] = $found;
276                }
277            }
278        }
279
280        return $result;
281    }
282
283    /**
284     * Finds relations of the given $rel=$type from $page
285     *
286     * This method will first look for relations in the page instance, then
287     * by searching the root container if nothing was found in the page.
288     *
289     * @param  AbstractPage $page page to find relations for
290     * @param  string       $rel  relation, "rel" or "rev"
291     * @param  string       $type link type, e.g. 'start', 'next'
292     * @return AbstractPage|array|null
293     * @throws Exception\DomainException if $rel is not "rel" or "rev"
294     */
295    public function findRelation(AbstractPage $page, $rel, $type)
296    {
297        if (!in_array($rel, array('rel', 'rev'))) {
298            throw new Exception\DomainException(sprintf(
299                'Invalid argument: $rel must be "rel" or "rev"; "%s" given',
300                $rel
301            ));
302        }
303
304        if (!$result = $this->findFromProperty($page, $rel, $type)) {
305            $result = $this->findFromSearch($page, $rel, $type);
306        }
307
308        return $result;
309    }
310
311    /**
312     * Finds relations of given $type for $page by checking if the
313     * relation is specified as a property of $page
314     *
315     * @param  AbstractPage $page  page to find relations for
316     * @param  string       $rel   relation, 'rel' or 'rev'
317     * @param  string       $type  link type, e.g. 'start', 'next'
318     * @return AbstractPage|array|null
319     */
320    protected function findFromProperty(AbstractPage $page, $rel, $type)
321    {
322        $method = 'get' . ucfirst($rel);
323        $result = $page->$method($type);
324        if ($result) {
325            $result = $this->convertToPages($result);
326            if ($result) {
327                if (!is_array($result)) {
328                    $result = array($result);
329                }
330
331                foreach ($result as $key => $page) {
332                    if (!$this->accept($page)) {
333                        unset($result[$key]);
334                    }
335                }
336
337                return count($result) == 1 ? $result[0] : $result;
338            }
339        }
340
341        return;
342    }
343
344    /**
345     * Finds relations of given $rel=$type for $page by using the helper to
346     * search for the relation in the root container
347     *
348     * @param  AbstractPage $page page to find relations for
349     * @param  string       $rel  relation, 'rel' or 'rev'
350     * @param  string       $type link type, e.g. 'start', 'next', etc
351     * @return array|null
352     */
353    protected function findFromSearch(AbstractPage $page, $rel, $type)
354    {
355        $found = null;
356
357        $method = 'search' . ucfirst($rel) . ucfirst($type);
358        if (method_exists($this, $method)) {
359            $found = $this->$method($page);
360        }
361
362        return $found;
363    }
364
365    // Search methods:
366
367    /**
368     * Searches the root container for the forward 'start' relation of the given
369     * $page
370     *
371     * From {@link http://www.w3.org/TR/html4/types.html#type-links}:
372     * Refers to the first document in a collection of documents. This link type
373     * tells search engines which document is considered by the author to be the
374     * starting point of the collection.
375     *
376     * @param  AbstractPage $page
377     * @return AbstractPage|null
378     */
379    public function searchRelStart(AbstractPage $page)
380    {
381        $found = $this->findRoot($page);
382        if (!$found instanceof AbstractPage) {
383            $found->rewind();
384            $found = $found->current();
385        }
386
387        if ($found === $page || !$this->accept($found)) {
388            $found = null;
389        }
390
391        return $found;
392    }
393
394    /**
395     * Searches the root container for the forward 'next' relation of the given
396     * $page
397     *
398     * From {@link http://www.w3.org/TR/html4/types.html#type-links}:
399     * Refers to the next document in a linear sequence of documents. User
400     * agents may choose to preload the "next" document, to reduce the perceived
401     * load time.
402     *
403     * @param  AbstractPage $page
404     * @return AbstractPage|null
405     */
406    public function searchRelNext(AbstractPage $page)
407    {
408        $found = null;
409        $break = false;
410        $iterator = new RecursiveIteratorIterator($this->findRoot($page), RecursiveIteratorIterator::SELF_FIRST);
411        foreach ($iterator as $intermediate) {
412            if ($intermediate === $page) {
413                // current page; break at next accepted page
414                $break = true;
415                continue;
416            }
417
418            if ($break && $this->accept($intermediate)) {
419                $found = $intermediate;
420                break;
421            }
422        }
423
424        return $found;
425    }
426
427    /**
428     * Searches the root container for the forward 'prev' relation of the given
429     * $page
430     *
431     * From {@link http://www.w3.org/TR/html4/types.html#type-links}:
432     * Refers to the previous document in an ordered series of documents. Some
433     * user agents also support the synonym "Previous".
434     *
435     * @param  AbstractPage $page
436     * @return AbstractPage|null
437     */
438    public function searchRelPrev(AbstractPage $page)
439    {
440        $found = null;
441        $prev = null;
442        $iterator = new RecursiveIteratorIterator(
443            $this->findRoot($page),
444            RecursiveIteratorIterator::SELF_FIRST
445        );
446        foreach ($iterator as $intermediate) {
447            if (!$this->accept($intermediate)) {
448                continue;
449            }
450            if ($intermediate === $page) {
451                $found = $prev;
452                break;
453            }
454
455            $prev = $intermediate;
456        }
457
458        return $found;
459    }
460
461    /**
462     * Searches the root container for forward 'chapter' relations of the given
463     * $page
464     *
465     * From {@link http://www.w3.org/TR/html4/types.html#type-links}:
466     * Refers to a document serving as a chapter in a collection of documents.
467     *
468     * @param  AbstractPage $page
469     * @return AbstractPage|array|null
470     */
471    public function searchRelChapter(AbstractPage $page)
472    {
473        $found = array();
474
475        // find first level of pages
476        $root = $this->findRoot($page);
477
478        // find start page(s)
479        $start = $this->findRelation($page, 'rel', 'start');
480        if (!is_array($start)) {
481            $start = array($start);
482        }
483
484        foreach ($root as $chapter) {
485            // exclude self and start page from chapters
486            if ($chapter !== $page &&
487                !in_array($chapter, $start) &&
488                $this->accept($chapter)) {
489                $found[] = $chapter;
490            }
491        }
492
493        switch (count($found)) {
494            case 0:
495                return;
496            case 1:
497                return $found[0];
498            default:
499                return $found;
500        }
501    }
502
503    /**
504     * Searches the root container for forward 'section' relations of the given
505     * $page
506     *
507     * From {@link http://www.w3.org/TR/html4/types.html#type-links}:
508     * Refers to a document serving as a section in a collection of documents.
509     *
510     * @param  AbstractPage $page
511     * @return AbstractPage|array|null
512     */
513    public function searchRelSection(AbstractPage $page)
514    {
515        $found = array();
516
517        // check if given page has pages and is a chapter page
518        if ($page->hasPages() && $this->findRoot($page)->hasPage($page)) {
519            foreach ($page as $section) {
520                if ($this->accept($section)) {
521                    $found[] = $section;
522                }
523            }
524        }
525
526        switch (count($found)) {
527            case 0:
528                return;
529            case 1:
530                return $found[0];
531            default:
532                return $found;
533        }
534    }
535
536    /**
537     * Searches the root container for forward 'subsection' relations of the
538     * given $page
539     *
540     * From {@link http://www.w3.org/TR/html4/types.html#type-links}:
541     * Refers to a document serving as a subsection in a collection of
542     * documents.
543     *
544     * @param  AbstractPage $page
545     * @return AbstractPage|array|null
546     */
547    public function searchRelSubsection(AbstractPage $page)
548    {
549        $found = array();
550
551        if ($page->hasPages()) {
552            // given page has child pages, loop chapters
553            foreach ($this->findRoot($page) as $chapter) {
554                // is page a section?
555                if ($chapter->hasPage($page)) {
556                    foreach ($page as $subsection) {
557                        if ($this->accept($subsection)) {
558                            $found[] = $subsection;
559                        }
560                    }
561                }
562            }
563        }
564
565        switch (count($found)) {
566            case 0:
567                return;
568            case 1:
569                return $found[0];
570            default:
571                return $found;
572        }
573    }
574
575    /**
576     * Searches the root container for the reverse 'section' relation of the
577     * given $page
578     *
579     * From {@link http://www.w3.org/TR/html4/types.html#type-links}:
580     * Refers to a document serving as a section in a collection of documents.
581     *
582     * @param  AbstractPage $page
583     * @return AbstractPage|null
584     */
585    public function searchRevSection(AbstractPage $page)
586    {
587        $found  = null;
588        $parent = $page->getParent();
589        if ($parent) {
590            if ($parent instanceof AbstractPage &&
591                $this->findRoot($page)->hasPage($parent)) {
592                $found = $parent;
593            }
594        }
595
596        return $found;
597    }
598
599    /**
600     * Searches the root container for the reverse 'section' relation of the
601     * given $page
602     *
603     * From {@link http://www.w3.org/TR/html4/types.html#type-links}:
604     * Refers to a document serving as a subsection in a collection of
605     * documents.
606     *
607     * @param  AbstractPage $page
608     * @return AbstractPage|null
609     */
610    public function searchRevSubsection(AbstractPage $page)
611    {
612        $found  = null;
613        $parent = $page->getParent();
614        if ($parent) {
615            if ($parent instanceof AbstractPage) {
616                $root = $this->findRoot($page);
617                foreach ($root as $chapter) {
618                    if ($chapter->hasPage($parent)) {
619                        $found = $parent;
620                        break;
621                    }
622                }
623            }
624        }
625
626        return $found;
627    }
628
629    // Util methods:
630
631    /**
632     * Returns the root container of the given page
633     *
634     * When rendering a container, the render method still store the given
635     * container as the root container, and unset it when done rendering. This
636     * makes sure finder methods will not traverse above the container given
637     * to the render method.
638     *
639     * @param  AbstractPage $page
640     * @return AbstractContainer
641     */
642    protected function findRoot(AbstractPage $page)
643    {
644        if ($this->root) {
645            return $this->root;
646        }
647
648        $root = $page;
649
650        while ($parent = $page->getParent()) {
651            $root = $parent;
652            if ($parent instanceof AbstractPage) {
653                $page = $parent;
654            } else {
655                break;
656            }
657        }
658
659        return $root;
660    }
661
662    /**
663     * Converts a $mixed value to an array of pages
664     *
665     * @param  mixed $mixed     mixed value to get page(s) from
666     * @param  bool  $recursive whether $value should be looped
667     *                          if it is an array or a config
668     * @return AbstractPage|array|null
669     */
670    protected function convertToPages($mixed, $recursive = true)
671    {
672        if ($mixed instanceof AbstractPage) {
673            // value is a page instance; return directly
674            return $mixed;
675        } elseif ($mixed instanceof AbstractContainer) {
676            // value is a container; return pages in it
677            $pages = array();
678            foreach ($mixed as $page) {
679                $pages[] = $page;
680            }
681            return $pages;
682        } elseif ($mixed instanceof Traversable) {
683            $mixed = ArrayUtils::iteratorToArray($mixed);
684        } elseif (is_string($mixed)) {
685            // value is a string; make a URI page
686            return AbstractPage::factory(array(
687                'type' => 'uri',
688                'uri'  => $mixed
689            ));
690        }
691
692        if (is_array($mixed) && !empty($mixed)) {
693            if ($recursive && is_numeric(key($mixed))) {
694                // first key is numeric; assume several pages
695                $pages = array();
696                foreach ($mixed as $value) {
697                    $value = $this->convertToPages($value, false);
698                    if ($value) {
699                        $pages[] = $value;
700                    }
701                }
702                return $pages;
703            } else {
704                // pass array to factory directly
705                try {
706                    $page = AbstractPage::factory($mixed);
707                    return $page;
708                } catch (\Exception $e) {
709                }
710            }
711        }
712
713        // nothing found
714        return;
715    }
716
717    /**
718     * Sets the helper's render flag
719     *
720     * The helper uses the bitwise '&' operator against the hex values of the
721     * render constants. This means that the flag can is "bitwised" value of
722     * the render constants. Examples:
723     * <code>
724     * // render all links except glossary
725     * $flag = Links:RENDER_ALL ^ Links:RENDER_GLOSSARY;
726     * $helper->setRenderFlag($flag);
727     *
728     * // render only chapters and sections
729     * $flag = Links:RENDER_CHAPTER | Links:RENDER_SECTION;
730     * $helper->setRenderFlag($flag);
731     *
732     * // render only relations that are not native W3C relations
733     * $helper->setRenderFlag(Links:RENDER_CUSTOM);
734     *
735     * // render all relations (default)
736     * $helper->setRenderFlag(Links:RENDER_ALL);
737     * </code>
738     *
739     * Note that custom relations can also be rendered directly using the
740     * {@link renderLink()} method.
741     *
742     * @param  int $renderFlag
743     * @return Links
744     */
745    public function setRenderFlag($renderFlag)
746    {
747        $this->renderFlag = (int) $renderFlag;
748
749        return $this;
750    }
751
752    /**
753     * Returns the helper's render flag
754     *
755     * @return int
756     */
757    public function getRenderFlag()
758    {
759        return $this->renderFlag;
760    }
761}
762