diff options
Diffstat (limited to 'modules/doc/library/Doc')
-rw-r--r-- | modules/doc/library/Doc/DocController.php | 116 | ||||
-rw-r--r-- | modules/doc/library/Doc/DocParser.php | 235 | ||||
-rw-r--r-- | modules/doc/library/Doc/DocSection.php | 159 | ||||
-rw-r--r-- | modules/doc/library/Doc/DocSectionFilterIterator.php | 73 | ||||
-rw-r--r-- | modules/doc/library/Doc/Exception/ChapterNotFoundException.php | 11 | ||||
-rw-r--r-- | modules/doc/library/Doc/Exception/DocException.php | 13 | ||||
-rw-r--r-- | modules/doc/library/Doc/Renderer/DocRenderer.php | 208 | ||||
-rw-r--r-- | modules/doc/library/Doc/Renderer/DocSearchRenderer.php | 131 | ||||
-rw-r--r-- | modules/doc/library/Doc/Renderer/DocSectionRenderer.php | 345 | ||||
-rw-r--r-- | modules/doc/library/Doc/Renderer/DocTocRenderer.php | 117 | ||||
-rw-r--r-- | modules/doc/library/Doc/Search/DocSearch.php | 95 | ||||
-rw-r--r-- | modules/doc/library/Doc/Search/DocSearchIterator.php | 114 | ||||
-rw-r--r-- | modules/doc/library/Doc/Search/DocSearchMatch.php | 215 |
13 files changed, 1832 insertions, 0 deletions
diff --git a/modules/doc/library/Doc/DocController.php b/modules/doc/library/Doc/DocController.php new file mode 100644 index 0000000..0caf3ad --- /dev/null +++ b/modules/doc/library/Doc/DocController.php @@ -0,0 +1,116 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc; + +use Icinga\Module\Doc\Renderer\DocSectionRenderer; +use Icinga\Module\Doc\Renderer\DocTocRenderer; +use Icinga\Web\Controller; +use Icinga\Web\Url; +use Icinga\Web\Widget\Tabextension\OutputFormat; + +class DocController extends Controller +{ + /** + * {@inheritdoc} + */ + protected function moduleInit() + { + // Our UrlParams object does not take parameters from custom routes into account which is why we have to set + // them explicitly + if ($this->hasParam('chapter')) { + $this->params->set('chapter', $this->getParam('chapter')); + } + if ($this->hasParam('image')) { + $this->params->set('image', $this->getParam('image')); + } + if ($this->hasParam('moduleName')) { + $this->params->set('moduleName', $this->getParam('moduleName')); + } + } + + /** + * Render a chapter + * + * @param string $path Path to the documentation + * @param string $chapter ID of the chapter + * @param string $url URL to replace links with + * @param string $imageUrl URL to images + * @param array $urlParams Additional URL parameters + */ + protected function renderChapter($path, $chapter, $url, $imageUrl = null, array $urlParams = array()) + { + $parser = new DocParser($path); + $section = new DocSectionRenderer($parser->getDocTree(), DocSectionRenderer::decodeUrlParam($chapter)); + $this->view->section = $section + ->setHighlightSearch($this->params->get('highlight-search')) + ->setImageUrl($imageUrl) + ->setUrl($url) + ->setUrlParams($urlParams); + $first = null; + foreach ($section as $first) { + break; + } + $title = $first === null ? ucfirst($chapter) : $first->getTitle(); + $this->view->title = $title; + $this->getTabs() + ->add('toc', array( + 'active' => true, + 'title' => $title, + 'url' => Url::fromRequest() + )) + ->extend(new OutputFormat(array(OutputFormat::TYPE_CSV, OutputFormat::TYPE_JSON))); + $this->render('chapter', null, true); + } + + /** + * Render a toc + * + * @param string $path Path to the documentation + * @param string $name Name of the documentation + * @param string $url URL to replace links with + * @param array $urlParams Additional URL parameters + */ + protected function renderToc($path, $name, $url, array $urlParams = array()) + { + $parser = new DocParser($path); + $toc = new DocTocRenderer($parser->getDocTree()->getIterator()); + $this->view->toc = $toc + ->setUrl($url) + ->setUrlParams($urlParams); + $name = ucfirst($name); + $title = sprintf($this->translate('%s Documentation'), $name); + $this->getTabs() + ->add('toc', array( + 'active' => true, + 'title' => $title, + 'url' => Url::fromRequest() + )) + ->extend(new OutputFormat(array(OutputFormat::TYPE_CSV, OutputFormat::TYPE_JSON))); + $this->render('toc', null, true); + } + + /** + * Render a pdf + * + * @param string $path Path to the documentation + * @param string $name Name of the documentation + * @param string $url + * @param array $urlParams + */ + protected function renderPdf($path, $name, $url, array $urlParams = array()) + { + $parser = new DocParser($path); + $toc = new DocTocRenderer($parser->getDocTree()->getIterator()); + $this->view->toc = $toc + ->setUrl($url) + ->setUrlParams($urlParams); + $section = new DocSectionRenderer($parser->getDocTree()); + $this->view->section = $section + ->setUrl($url) + ->setUrlParams($urlParams); + $this->view->title = sprintf($this->translate('%s Documentation'), $name); + $this->_request->setParam('format', 'pdf'); + $this->_helper->viewRenderer->setRender('pdf', null, true); + } +} diff --git a/modules/doc/library/Doc/DocParser.php b/modules/doc/library/Doc/DocParser.php new file mode 100644 index 0000000..7ddeaa9 --- /dev/null +++ b/modules/doc/library/Doc/DocParser.php @@ -0,0 +1,235 @@ +<?php +/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc; + +use CachingIterator; +use RecursiveIteratorIterator; +use SplFileObject; +use SplStack; +use Icinga\Data\Tree\SimpleTree; +use Icinga\Exception\NotReadableError; +use Icinga\Util\DirectoryIterator; +use Icinga\Module\Doc\Exception\DocException; + +/** + * Parser for documentation written in Markdown + */ +class DocParser +{ + /** + * Internal identifier for Atx-style headers + * + * @var int + */ + const HEADER_ATX = 1; + + /** + * Internal identifier for Setext-style headers + * + * @var int + */ + const HEADER_SETEXT = 2; + + /** + * Path to the documentation + * + * @var string + */ + protected $path; + + /** + * Iterator over documentation files + * + * @var DirectoryIterator + */ + protected $docIterator; + + /** + * Create a new documentation parser for the given path + * + * @param string $path Path to the documentation + * + * @throws DocException If the documentation directory does not exist + * @throws NotReadableError If the documentation directory is not readable + */ + public function __construct($path) + { + if (! DirectoryIterator::isReadable($path)) { + throw new DocException( + mt('doc', 'Documentation directory \'%s\' is not readable'), + $path + ); + } + $this->path = $path; + $this->docIterator = new DirectoryIterator($path, 'md', DirectoryIterator::FILES_FIRST); + } + + /** + * Extract atx- or setext-style headers from the given lines + * + * @param string $line + * @param string $nextLine + * + * @return array|null An array containing the header and the header level or null if there's nothing to extract + */ + protected function extractHeader($line, $nextLine) + { + if (! $line) { + return null; + } + $header = null; + if ($line + && $line[0] === '#' + && preg_match('/^#+/', $line, $match) === 1 + ) { + // Atx + $level = strlen($match[0]); + $header = trim(substr($line, $level)); + if (! $header) { + return null; + } + $headerStyle = static::HEADER_ATX; + } elseif ($nextLine + && ($nextLine[0] === '=' || $nextLine[0] === '-') + && preg_match('/^[=-]+\s*$/', $nextLine, $match) === 1 + ) { + // Setext + $header = trim($line); + if (! $header) { + return null; + } + if ($match[0][0] === '=') { + $level = 1; + } else { + $level = 2; + } + $headerStyle = static::HEADER_SETEXT; + } + if ($header === null) { + return null; + } + if (strpos($header, '<') !== false + && preg_match('#(?:<(?P<tag>a|span) (?:id|name)="(?P<id>.+)"></(?P=tag)>)\s*#u', $header, $match) + ) { + $header = str_replace($match[0], '', $header); + $id = $match['id']; + } else { + $id = null; + } + /** @noinspection PhpUndefinedVariableInspection */ + return array($header, $id, $level, $headerStyle); + } + + /** + * Generate unique section ID + * + * @param string $id + * @param string $filename + * @param SimpleTree $tree + * + * @return string + */ + protected function uuid($id, $filename, SimpleTree $tree) + { + $id = str_replace(' ', '-', $id); + if ($tree->getNode($id) === null) { + return $id; + } + $id = $id . '-' . md5($filename); + $offset = 0; + while ($tree->getNode($id)) { + if ($offset++ === 0) { + $id .= '-' . $offset; + } else { + $id = substr($id, 0, -1) . $offset; + } + } + return $id; + } + + /** + * Get the documentation tree + * + * @return SimpleTree + */ + public function getDocTree() + { + $tree = new SimpleTree(); + foreach (new RecursiveIteratorIterator($this->docIterator) as $filename) { + $file = new SplFileObject($filename); + $file->setFlags(SplFileObject::READ_AHEAD); + $stack = new SplStack(); + $cachingIterator = new CachingIterator($file); + $insideFencedCodeBlock = false; + + for ($cachingIterator->rewind(); $cachingIterator->valid(); $cachingIterator->next()) { + $line = $cachingIterator->current(); + $header = null; + + if (substr($line, 0, 3) === '```') { + $insideFencedCodeBlock = ! $insideFencedCodeBlock; + } elseif (! $insideFencedCodeBlock) { + $fileIterator = $cachingIterator->getInnerIterator(); + $header = $this->extractHeader($line, $fileIterator->valid() ? $fileIterator->current() : null); + } + + if ($header !== null) { + list($title, $id, $level, $headerStyle) = $header; + while (! $stack->isEmpty() && $stack->top()->getLevel() >= $level) { + $stack->pop(); + } + if ($id === null) { + $path = array(); + foreach ($stack as $section) { + /** @var $section DocSection */ + $path[] = $section->getTitle(); + } + $path[] = $title; + $id = implode('-', $path); + $noFollow = true; + } else { + $noFollow = false; + } + + $id = $this->uuid($id, $filename, $tree); + + $section = new DocSection(); + $section + ->setId($id) + ->setTitle($title) + ->setLevel($level) + ->setNoFollow($noFollow); + if ($stack->isEmpty()) { + $section->setChapter($section); + $tree->addChild($section); + } else { + $section->setChapter($stack->bottom()); + $tree->addChild($section, $stack->top()); + } + $stack->push($section); + if ($headerStyle === static::HEADER_SETEXT) { + $cachingIterator->next(); + continue; + } + } else { + if ($stack->isEmpty()) { + $title = ucfirst($file->getBasename('.' . pathinfo($file->getFilename(), PATHINFO_EXTENSION))); + $id = $this->uuid($title, $filename, $tree); + $section = new DocSection(); + $section + ->setId($id) + ->setTitle($title) + ->setLevel(1) + ->setNoFollow(true); + $section->setChapter($section); + $tree->addChild($section); + $stack->push($section); + } + $stack->top()->appendContent($line); + } + } + } + return $tree; + } +} diff --git a/modules/doc/library/Doc/DocSection.php b/modules/doc/library/Doc/DocSection.php new file mode 100644 index 0000000..ce5297e --- /dev/null +++ b/modules/doc/library/Doc/DocSection.php @@ -0,0 +1,159 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc; + +use Icinga\Data\Tree\TreeNode; + +/** + * A section of a documentation + */ +class DocSection extends TreeNode +{ + /** + * Chapter the section belongs to + * + * @var DocSection + */ + protected $chapter; + + /** + * Content of the section + * + * @var array + */ + protected $content = array(); + + /** + * Header level + * + * @var int + */ + protected $level; + + /** + * Whether to instruct search engines to not index the link to the section + * + * @var bool + */ + protected $noFollow; + + /** + * Title of the section + * + * @var string + */ + protected $title; + + /** + * Set the chapter the section belongs to + * + * @param DocSection $section + * + * @return $this + */ + public function setChapter(DocSection $section) + { + $this->chapter = $section; + return $this; + } + + /** + * Get the chapter the section belongs to + * + * @return DocSection + */ + public function getChapter() + { + return $this->chapter; + } + + /** + * Append content + * + * @param string $content + */ + public function appendContent($content) + { + $this->content[] = $content; + } + + /** + * Get the content of the section + * + * @return array + */ + public function getContent() + { + return $this->content; + } + + /** + * Set the header level + * + * @param int $level Header level + * + * @return $this + */ + public function setLevel($level) + { + $this->level = (int) $level; + return $this; + } + + /** + * Get the header level + * + * @return int + */ + public function getLevel() + { + return $this->level; + } + + /** + * Set whether to instruct search engines to not index the link to the section + * + * @param bool $noFollow Whether to instruct search engines to not index the link to the section + * + * @return $this + */ + public function setNoFollow($noFollow = true) + { + $this->noFollow = (bool) $noFollow; + return $this; + } + + /** + * Get whether to instruct search engines to not index the link to the section + * + * @return bool + */ + public function getNoFollow() + { + return $this->noFollow; + } + + /** + * Set the title of the section + * + * @param string $title Title of the section + * + * @return $this + */ + public function setTitle($title) + { + $this->title = (string) $title; + return $this; + } + + /** + * Get the title of the section + * + * @return string + */ + public function getTitle() + { + return $this->title; + } +} diff --git a/modules/doc/library/Doc/DocSectionFilterIterator.php b/modules/doc/library/Doc/DocSectionFilterIterator.php new file mode 100644 index 0000000..bac5a67 --- /dev/null +++ b/modules/doc/library/Doc/DocSectionFilterIterator.php @@ -0,0 +1,73 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc; + +use Countable; +use RecursiveFilterIterator; +use Icinga\Data\Tree\TreeNodeIterator; + +/** + * Recursive filter iterator over sections that are part of a particular chapter + * + * @method TreeNodeIterator getInnerIterator() { + * {@inheritdoc} + * } + */ +class DocSectionFilterIterator extends RecursiveFilterIterator implements Countable +{ + /** + * Chapter to filter for + * + * @var string + */ + protected $chapter; + + /** + * Create a new recursive filter iterator over sections that are part of a particular chapter + * + * @param TreeNodeIterator $iterator + * @param string $chapter The chapter to filter for + */ + public function __construct(TreeNodeIterator $iterator, $chapter) + { + parent::__construct($iterator); + $this->chapter = $chapter; + } + + /** + * Accept sections that are part of the given chapter + * + * @return bool Whether the current element of the iterator is acceptable + * through this filter + */ + public function accept(): bool + { + $section = $this->current(); + /** @var \Icinga\Module\Doc\DocSection $section */ + if ($section->getChapter()->getId() === $this->chapter) { + return true; + } + return false; + } + + public function getChildren(): self + { + return new static($this->getInnerIterator()->getChildren(), $this->chapter); + } + + public function count(): int + { + return iterator_count($this); + } + + /** + * Whether the filter swallowed every section + * + * @return bool + */ + public function isEmpty() + { + return $this->count() === 0; + } +} diff --git a/modules/doc/library/Doc/Exception/ChapterNotFoundException.php b/modules/doc/library/Doc/Exception/ChapterNotFoundException.php new file mode 100644 index 0000000..7fa7807 --- /dev/null +++ b/modules/doc/library/Doc/Exception/ChapterNotFoundException.php @@ -0,0 +1,11 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Exception; + +/** + * Exception thrown if a chapter was not found + */ +class ChapterNotFoundException extends DocException +{ +} diff --git a/modules/doc/library/Doc/Exception/DocException.php b/modules/doc/library/Doc/Exception/DocException.php new file mode 100644 index 0000000..1d9e871 --- /dev/null +++ b/modules/doc/library/Doc/Exception/DocException.php @@ -0,0 +1,13 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Exception; + +use Icinga\Exception\IcingaException; + +/** + * Exception thrown if an error in the documentation module's library occurs + */ +class DocException extends IcingaException +{ +} diff --git a/modules/doc/library/Doc/Renderer/DocRenderer.php b/modules/doc/library/Doc/Renderer/DocRenderer.php new file mode 100644 index 0000000..cb1bc39 --- /dev/null +++ b/modules/doc/library/Doc/Renderer/DocRenderer.php @@ -0,0 +1,208 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Renderer; + +use Exception; +use Icinga\Exception\IcingaException; +use RecursiveIteratorIterator; +use Icinga\Application\Icinga; +use Icinga\Web\View; + +/** + * Base class for toc and section renderer + */ +abstract class DocRenderer extends RecursiveIteratorIterator +{ + /** + * URL to images + * + * @var string + */ + protected $imageUrl; + + /** + * URL to replace links with + * + * @var string + */ + protected $url; + + /** + * Additional URL parameters + * + * @var array + */ + protected $urlParams = array(); + + /** + * View + * + * @var View|null + */ + protected $view; + + /** + * Get the URL to images + * + * @return string + */ + public function getImageUrl() + { + return $this->imageUrl; + } + + /** + * Set the URL to images + * + * @param string $imageUrl + * + * @return $this + */ + public function setImageUrl($imageUrl) + { + $this->imageUrl = (string) $imageUrl; + return $this; + } + /** + * Get the URL to replace links with + * + * @return string + */ + public function getUrl() + { + return $this->url; + } + + /** + * Set the URL to replace links with + * + * @param string $url + * + * @return $this + */ + public function setUrl($url) + { + $this->url = (string) $url; + return $this; + } + + /** + * Get additional URL parameters + * + * @return array + */ + public function getUrlParams() + { + return $this->urlParams; + } + + /** + * Set additional URL parameters + * + * @param array $urlParams + * + * @return $this + */ + public function setUrlParams(array $urlParams) + { + $this->urlParams = array_map(array($this, 'encodeUrlParam'), $urlParams); + return $this; + } + + /** + * Get the view + * + * @return View + */ + public function getView() + { + if ($this->view === null) { + $this->view = Icinga::app()->getViewRenderer()->view; + } + return $this->view; + } + + /** + * Set the view + * + * @param View $view + * + * @return $this + */ + public function setView(View $view) + { + $this->view = $view; + return $this; + } + + /** + * Encode an anchor identifier + * + * @param string $anchor + * + * @return string + */ + public static function encodeAnchor($anchor) + { + return rawurlencode($anchor); + } + + /** + * Decode an anchor identifier + * + * @param string $anchor + * + * @return string + */ + public static function decodeAnchor($anchor) + { + return rawurldecode($anchor); + } + + /** + * Encode a URL parameter + * + * @param string $param + * + * @return string + */ + public static function encodeUrlParam($param) + { + return str_replace(array('%2F','%5C'), array('%252F','%255C'), rawurlencode($param)); + } + + /** + * Decode a URL parameter + * + * @param string $param + * + * @return string + */ + public static function decodeUrlParam($param) + { + return str_replace(array('%2F', '%5C'), array('/', '\\'), $param); + } + + /** + * Render to HTML + * + * @return string + */ + abstract public function render(); + + /** + * Render to HTML + * + * @return string + * @see \Icinga\Module\Doc\Renderer::render() For the render method. + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + return $e->getMessage() . ': ' . IcingaException::getConfidentialTraceAsString($e); + } + } +} diff --git a/modules/doc/library/Doc/Renderer/DocSearchRenderer.php b/modules/doc/library/Doc/Renderer/DocSearchRenderer.php new file mode 100644 index 0000000..c6e9ae2 --- /dev/null +++ b/modules/doc/library/Doc/Renderer/DocSearchRenderer.php @@ -0,0 +1,131 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Renderer; + +use RecursiveIteratorIterator; +use Icinga\Module\Doc\Search\DocSearchIterator; +use Icinga\Module\Doc\Search\DocSearchMatch; + +/** + * Renderer for doc searches + */ +class DocSearchRenderer extends DocRenderer +{ + /** + * The content to render + * + * @var array + */ + protected $content = array(); + + /** + * Create a new renderer for doc searches + * + * @param DocSearchIterator $iterator + */ + public function __construct(DocSearchIterator $iterator) + { + parent::__construct($iterator, RecursiveIteratorIterator::SELF_FIRST); + } + + public function beginIteration(): void + { + $this->content[] = '<nav role="navigation"><ul class="toc">'; + } + + public function endIteration(): void + { + $this->content[] = '</ul></nav>'; + } + + public function beginChildren(): void + { + if ($this->getInnerIterator()->getMatches()) { + $this->content[] = '<ul class="toc">'; + } + } + + public function endChildren(): void + { + if ($this->getInnerIterator()->getMatches()) { + $this->content[] = '</ul>'; + } + } + + public function render() + { + foreach ($this as $section) { + if (($matches = $this->getInnerIterator()->getMatches()) === null) { + continue; + } + $title = $this->getView()->escape($section->getTitle()); + $contentMatches = array(); + foreach ($matches as $match) { + if ($match->getMatchType() === DocSearchMatch::MATCH_HEADER) { + $title = $match->highlight(); + } else { + $contentMatches[] = sprintf( + '<p>%s</p>', + $match->highlight() + ); + } + } + $path = $this->getView()->getHelper('Url')->url( + array_merge( + $this->getUrlParams(), + array( + 'chapter' => $this->encodeUrlParam($section->getChapter()->getId()) + ) + ), + $this->url, + false, + false + ); + $url = $this->getView()->url( + $path, + array('highlight-search' => $this->getInnerIterator()->getSearch()->getInput()) + ); + /** @var \Icinga\Web\Url $url */ + $url->setAnchor($this->encodeAnchor($section->getId())); + $urlAttributes = array( + 'data-base-target' => '_next', + 'title' => $section->getId() === $section->getChapter()->getId() + ? sprintf( + $this->getView()->translate( + 'Show all matches of "%s" in the chapter "%s"', + 'search.render.section.link' + ), + $this->getInnerIterator()->getSearch()->getInput(), + $section->getChapter()->getTitle() + ) + : sprintf( + $this->getView()->translate( + 'Show all matches of "%s" in the section "%s" of the chapter "%s"', + 'search.render.section.link' + ), + $this->getInnerIterator()->getSearch()->getInput(), + $section->getTitle(), + $section->getChapter()->getTitle() + ) + ); + if ($section->getNoFollow()) { + $urlAttributes['rel'] = 'nofollow'; + } + $this->content[] = '<li>' . $this->getView()->qlink( + $title, + $url->getAbsoluteUrl(), + null, + $urlAttributes, + false + ); + if (! empty($contentMatches)) { + $this->content = array_merge($this->content, $contentMatches); + } + if (! $section->hasChildren()) { + $this->content[] = '</li>'; + } + } + return implode("\n", $this->content); + } +} diff --git a/modules/doc/library/Doc/Renderer/DocSectionRenderer.php b/modules/doc/library/Doc/Renderer/DocSectionRenderer.php new file mode 100644 index 0000000..c61dfac --- /dev/null +++ b/modules/doc/library/Doc/Renderer/DocSectionRenderer.php @@ -0,0 +1,345 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Renderer; + +use DOMDocument; +use DOMXPath; +use Icinga\Module\Doc\DocSection; +use Parsedown; +use RecursiveIteratorIterator; +use Icinga\Data\Tree\SimpleTree; +use Icinga\Module\Doc\Exception\ChapterNotFoundException; +use Icinga\Module\Doc\DocSectionFilterIterator; +use Icinga\Module\Doc\Search\DocSearch; +use Icinga\Module\Doc\Search\DocSearchMatch; +use Icinga\Web\Dom\DomNodeIterator; +use Icinga\Web\Url; +use Icinga\Web\View; + +/** + * Section renderer + */ +class DocSectionRenderer extends DocRenderer +{ + /** + * Content to render + * + * @var array + */ + protected $content = array(); + + /** + * Search criteria to highlight + * + * @var string + */ + protected $highlightSearch; + + /** + * Parsedown instance + * + * @var Parsedown + */ + protected $parsedown; + + /** + * Documentation tree + * + * @var SimpleTree + */ + protected $tree; + + /** + * Create a new section renderer + * + * @param SimpleTree $tree The documentation tree + * @param string|null $chapter If not null, the chapter to filter for + * + * @throws ChapterNotFoundException If the chapter to filter for was not found + */ + public function __construct(SimpleTree $tree, $chapter = null) + { + if ($chapter !== null) { + $filter = new DocSectionFilterIterator($tree->getIterator(), $chapter); + if ($filter->isEmpty()) { + throw new ChapterNotFoundException( + mt('doc', 'Chapter %s not found'), + $chapter + ); + } + parent::__construct( + $filter, + RecursiveIteratorIterator::SELF_FIRST + ); + } else { + parent::__construct($tree->getIterator(), RecursiveIteratorIterator::SELF_FIRST); + } + $this->tree = $tree; + $this->parsedown = Parsedown::instance(); + } + + /** + * Set the search criteria to highlight + * + * @param string $highlightSearch + * + * @return $this + */ + public function setHighlightSearch($highlightSearch) + { + $this->highlightSearch = $highlightSearch; + return $this; + } + + /** + * Get the search criteria to highlight + * + * @return string + */ + public function getHighlightSearch() + { + return $this->highlightSearch; + } + + /** + * Syntax highlighting for PHP code + * + * @param array $match + * + * @return string + */ + protected function highlightPhp($match) + { + return '<pre>' . highlight_string(htmlspecialchars_decode($match[1]), true) . '</pre>'; + } + + /** + * Highlight search criteria + * + * @param string $html + * @param DocSearch $search Search criteria + * + * @return string + */ + protected function highlightSearch($html, DocSearch $search) + { + $doc = new DOMDocument(); + @$doc->loadHTML($html); + $iter = new RecursiveIteratorIterator(new DomNodeIterator($doc), RecursiveIteratorIterator::SELF_FIRST); + foreach ($iter as $node) { + if ($node->nodeType !== XML_TEXT_NODE + || ($node->parentNode->nodeType === XML_ELEMENT_NODE && $node->parentNode->tagName === 'code') + ) { + continue; + } + $text = $node->nodeValue; + if (($match = $search->search($text)) === null) { + continue; + } + $matches = $match->getMatches(); + ksort($matches); + $offset = 0; + $fragment = $doc->createDocumentFragment(); + foreach ($matches as $position => $match) { + $fragment->appendChild($doc->createTextNode(substr($text, $offset, $position - $offset))); + $fragment->appendChild($doc->createElement('span', $match)) + ->setAttribute('class', DocSearchMatch::HIGHLIGHT_CSS_CLASS); + $offset = $position + strlen($match); + } + $fragment->appendChild($doc->createTextNode(substr($text, $offset))); + $node->parentNode->replaceChild($fragment, $node); + } + // Remove <!DOCTYPE + $doc->removeChild($doc->doctype); + // Remove <html><body> and </body></html> + return substr($doc->saveHTML(), 12, -15); + } + + /** + * Markup notes + * + * @param array $match + * + * @return string + */ + protected function markupNotes($match) + { + $doc = new DOMDocument(); + $doc->loadHTML($match[0]); + $xpath = new DOMXPath($doc); + $blockquote = $xpath->query('//blockquote[1]')->item(0); + /** @var \DOMElement $blockquote */ + if (strtolower(substr(trim($blockquote->nodeValue), 0, 5)) === 'note:') { + $blockquote->setAttribute('class', 'note'); + } + return $doc->saveXML($blockquote); + } + + /** + * Replace img src tags + * + * @param $match + * + * @return string + */ + protected function replaceImg($match) + { + $doc = new DOMDocument(); + $doc->loadHTML($match[0]); + $xpath = new DOMXPath($doc); + $img = $xpath->query('//img[1]')->item(0); + /** @var \DOMElement $img */ + $path = $this->getView()->getHelper('Url')->url( + array_merge( + array( + 'image' => trim($img->getAttribute('src')) + ), + $this->urlParams + ), + $this->imageUrl, + false, + false + ); + $url = $this->getView()->url($path); + /** @var Url $url */ + $img->setAttribute('src', $url->getAbsoluteUrl()); + return substr_replace($doc->saveXML($img), '', -2, 1); // Replace '/>' with '>' + } + + /** + * Replace chapter link + * + * @param array $match + * + * @return string + */ + protected function replaceChapterLink($match) + { + if (($chapter = $this->tree->getNode($this->decodeAnchor($match['chapter']))) === null) { + return $match[0]; + } + /** @var DocSection $section */ + $path = $this->getView()->getHelper('Url')->url( + array_merge( + $this->urlParams, + array( + 'chapter' => $this->encodeUrlParam($chapter->getChapter()->getId()) + ) + ), + $this->url, + false, + false + ); + $url = $this->getView()->url($path); + /** @var Url $url */ + return sprintf( + '<a %s%shref="%s"', + strlen($match['attribs']) ? trim($match['attribs']) . ' ' : '', + $chapter->getNoFollow() ? 'rel="nofollow" ' : '', + $url->getAbsoluteUrl() + ); + } + + /** + * Replace section link + * + * @param array $match + * + * @return string + */ + protected function replaceSectionLink($match) + { + if (($section = $this->tree->getNode($this->decodeAnchor($match['section']))) === null) { + return $match[0]; + } + /** @var DocSection $section */ + $path = $this->getView()->getHelper('Url')->url( + array_merge( + $this->urlParams, + array( + 'chapter' => $this->encodeUrlParam($section->getChapter()->getId()) + ) + ), + $this->url, + false, + false + ); + $url = $this->getView()->url($path); + /** @var Url $url */ + $url->setAnchor($this->encodeAnchor($section->getId())); + return sprintf( + '<a %s%shref="%s"', + strlen($match['attribs']) ? trim($match['attribs']) . ' ' : '', + $section->getNoFollow() ? 'rel="nofollow" ' : '', + $url->getAbsoluteUrl() + ); + } + + /** + * {@inheritdoc} + */ + public function render() + { + $search = null; + if (($highlightSearch = $this->getHighlightSearch()) !== null) { + $search = new DocSearch($highlightSearch); + } + foreach ($this as $section) { + $title = $section->getTitle(); + if ($search !== null && ($match = $search->search($title)) !== null) { + $title = $match->highlight(); + } else { + $title = $this->getView()->escape($title); + } + $number = ''; + for ($i = 0; $i < $this->getDepth() + 1; ++$i) { + if ($i > 0) { + $number .= '.'; + } + $number .= $this->getSubIterator($i)->key() + 1; + } + $this->content[] = sprintf( + '<a name="%1$s"></a><h%2$d>%3$s. %4$s</h%2$d>', + static::encodeAnchor($section->getId()), + $section->getLevel(), + $number, + $title + ); + $html = $this->parsedown->text(implode('', $section->getContent())); + if (empty($html)) { + continue; + } + $html = preg_replace_callback( + '#<pre><code class="language-php">(.*?)</code></pre>#s', + array($this, 'highlightPhp'), + $html + ); + $html = preg_replace_callback( + '/<img[^>]+>/', + array($this, 'replaceImg'), + $html + ); + $html = preg_replace_callback( + '#<blockquote>.+?</blockquote>#ms', + array($this, 'markupNotes'), + $html + ); + $html = preg_replace_callback( + '/<a\s+(?P<attribs>[^>]*?\s+)?href="(?:(?!http:\/\/)[^"#]*)#(?P<section>[^"]+)"/', + array($this, 'replaceSectionLink'), + $html + ); + $html = preg_replace_callback( + '/<a\s+(?P<attribs>[^>]*?\s+)?href="(?:\d+-)?(?P<chapter>[^\/"#]+).md"/', + array($this, 'replaceChapterLink'), + $html + ); + if ($search !== null) { + $html = $this->highlightSearch($html, $search); + } + $this->content[] = $html; + } + return implode("\n", $this->content); + } +} diff --git a/modules/doc/library/Doc/Renderer/DocTocRenderer.php b/modules/doc/library/Doc/Renderer/DocTocRenderer.php new file mode 100644 index 0000000..09e9a1d --- /dev/null +++ b/modules/doc/library/Doc/Renderer/DocTocRenderer.php @@ -0,0 +1,117 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Renderer; + +use Icinga\Data\Tree\TreeNodeIterator; +use RecursiveIteratorIterator; + +/** + * TOC renderer + */ +class DocTocRenderer extends DocRenderer +{ + /** + * CSS class for the HTML list element + * + * @var string + */ + const CSS_CLASS = 'toc'; + + /** + * Tag for the HTML list element + * + * @var string + */ + const HTML_LIST_TAG = 'ol'; + + /** + * Content to render + * + * @var array + */ + protected $content = array(); + + /** + * Create a new toc renderer + * + * @param TreeNodeIterator $iterator + */ + public function __construct(TreeNodeIterator $iterator) + { + parent::__construct($iterator, RecursiveIteratorIterator::SELF_FIRST); + } + + public function beginIteration(): void + { + $this->content[] = sprintf('<nav role="navigation"><%s class="%s">', static::HTML_LIST_TAG, static::CSS_CLASS); + } + + public function endIteration(): void + { + $this->content[] = sprintf('</%s></nav>', static::HTML_LIST_TAG); + } + + public function beginChildren(): void + { + $this->content[] = sprintf('<%s class="%s">', static::HTML_LIST_TAG, static::CSS_CLASS); + } + + public function endChildren(): void + { + $this->content[] = sprintf('</%s>', static::HTML_LIST_TAG); + } + + public function render() + { + if ($this->getInnerIterator()->isEmpty()) { + return '<p>' . mt('doc', 'Documentation is empty.') . '</p>'; + } + $view = $this->getView(); + $zendUrlHelper = $view->getHelper('Url'); + foreach ($this as $section) { + $path = $zendUrlHelper->url( + array_merge( + $this->urlParams, + array( + 'chapter' => $this->encodeUrlParam($section->getChapter()->getId()) + ) + ), + $this->url, + false, + false + ); + $url = $view->url($path); + /** @var \Icinga\Web\Url $url */ + if ($this->getDepth() > 0) { + $url->setAnchor($this->encodeAnchor($section->getId())); + } + $urlAttributes = array( + 'data-base-target' => '_next', + 'title' => $section->getId() === $section->getChapter()->getId() + ? sprintf( + $view->translate('Show the chapter "%s"', 'toc.render.section.link'), + $section->getChapter()->getTitle() + ) + : sprintf( + $view->translate('Show the section "%s" of the chapter "%s"', 'toc.render.section.link'), + $section->getTitle(), + $section->getChapter()->getTitle() + ) + ); + if ($section->getNoFollow()) { + $urlAttributes['rel'] = 'nofollow'; + } + $this->content[] = '<li>' . $this->getView()->qlink( + $section->getTitle(), + $url->getAbsoluteUrl(), + null, + $urlAttributes + ); + if (! $section->hasChildren()) { + $this->content[] = '</li>'; + } + } + return implode("\n", $this->content); + } +} diff --git a/modules/doc/library/Doc/Search/DocSearch.php b/modules/doc/library/Doc/Search/DocSearch.php new file mode 100644 index 0000000..20493e4 --- /dev/null +++ b/modules/doc/library/Doc/Search/DocSearch.php @@ -0,0 +1,95 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Search; + +/** + * Search documentation for a given search string + */ +class DocSearch +{ + /** + * Search string + * + * @var string + */ + protected $input; + + /** + * Search criteria + * + * @var array + */ + protected $search; + + /** + * Create a new doc search from the given search string + * + * @param string $search + */ + public function __construct($search) + { + $this->input = $search = (string) $search; + $criteria = array(); + if (preg_match_all('/"(?P<search>[^"]*)"/', $search, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { + $unquoted = array(); + $offset = 0; + foreach ($matches as $match) { + $fullMatch = $match[0]; + $searchMatch = $match['search']; + $unquoted[] = substr($search, $offset, $fullMatch[1] - $offset); + $offset = $fullMatch[1] + strlen($fullMatch[0]); + if (strlen($searchMatch[0]) > 0) { + $criteria[] = $searchMatch[0]; + } + } + $unquoted[] = substr($search, $offset); + $search = implode(' ', $unquoted); + } + $this->search = array_map( + 'strtolower', + array_unique(array_merge($criteria, array_filter(explode(' ', trim($search))))) + ); + } + + /** + * Get the search criteria + * + * @return array + */ + public function getCriteria() + { + return $this->search; + } + + /** + * Get the search string + * + * @return string + */ + public function getInput() + { + return $this->input; + } + + /** + * Search in the given line + * + * @param string $line + * + * @return DocSearchMatch|null + */ + public function search($line) + { + $match = new DocSearchMatch(); + $match->setLine($line); + foreach ($this->search as $criteria) { + $offset = 0; + while (($position = stripos($line, $criteria, $offset)) !== false) { + $match->appendMatch(substr($line, $position, strlen($criteria)), $position); + $offset = $position + 1; + } + } + return $match->isEmpty() ? null : $match; + } +} diff --git a/modules/doc/library/Doc/Search/DocSearchIterator.php b/modules/doc/library/Doc/Search/DocSearchIterator.php new file mode 100644 index 0000000..f262b5d --- /dev/null +++ b/modules/doc/library/Doc/Search/DocSearchIterator.php @@ -0,0 +1,114 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Search; + +use Icinga\Module\Doc\DocSection; +use RecursiveFilterIterator; +use RecursiveIteratorIterator; +use Icinga\Data\Tree\TreeNodeIterator; + +/** + * Iterator over doc sections that match a given search criteria + */ +class DocSearchIterator extends RecursiveFilterIterator +{ + /** + * Search criteria + * + * @var DocSearch + */ + protected $search; + + /** + * Current search matches + * + * @var DocSearchMatch[]|null + */ + protected $matches; + + /** + * Create a new iterator over doc sections that match the given search criteria + * + * @param TreeNodeIterator $iterator + * @param DocSearch $search + */ + public function __construct(TreeNodeIterator $iterator, DocSearch $search) + { + $this->search = $search; + parent::__construct($iterator); + } + + /** + * Accept sections that match the search + * + * @return bool Whether the current element of the iterator is acceptable + * through this filter + */ + public function accept(): bool + { + /** @var $section DocSection */ + $section = $this->current(); + $matches = array(); + if (($match = $this->search->search($section->getTitle())) !== null) { + $matches[] = $match->setMatchType(DocSearchMatch::MATCH_HEADER); + } + foreach ($section->getContent() as $lineno => $line) { + if (($match = $this->search->search($line)) !== null) { + $matches[] = $match + ->setMatchType(DocSearchMatch::MATCH_CONTENT) + ->setLineno($lineno); + } + } + if (! empty($matches)) { + $this->matches = $matches; + return true; + } + if ($section->hasChildren()) { + $this->matches = null; + return true; + } + return false; + } + + /** + * Get the search criteria + * + * @return DocSearch + */ + public function getSearch() + { + return $this->search; + } + + public function getChildren(): self + { + return new static($this->getInnerIterator()->getChildren(), $this->search); + } + + /** + * Whether the search did not yield any match + * + * @return bool + */ + public function isEmpty() + { + $iter = new RecursiveIteratorIterator($this, RecursiveIteratorIterator::SELF_FIRST); + foreach ($iter as $section) { + if ($iter->getInnerIterator()->getMatches() !== null) { + return false; + } + } + return true; + } + + /** + * Get current matches + * + * @return DocSearchMatch[]|null + */ + public function getMatches() + { + return $this->matches; + } +} diff --git a/modules/doc/library/Doc/Search/DocSearchMatch.php b/modules/doc/library/Doc/Search/DocSearchMatch.php new file mode 100644 index 0000000..0f21748 --- /dev/null +++ b/modules/doc/library/Doc/Search/DocSearchMatch.php @@ -0,0 +1,215 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Doc\Search; + +use UnexpectedValueException; +use Icinga\Application\Icinga; +use Icinga\Web\View; + +/** + * A doc search match + */ +class DocSearchMatch +{ + /** + * CSS class for highlighting matches + * + * @var string + */ + const HIGHLIGHT_CSS_CLASS = 'search-highlight'; + + /** + * Header match + * + * @var int + */ + const MATCH_HEADER = 1; + + /** + * Content match + * + * @var int + */ + const MATCH_CONTENT = 2; + + /** + * Line + * + * @var string + */ + protected $line; + + /** + * Line number + * + * @var int + */ + protected $lineno; + + /** + * Type of the match + * + * @var int + */ + protected $matchType; + + /** + * Matches + * + * @var array + */ + protected $matches = array(); + + /** + * View + * + * @var View|null + */ + protected $view; + + /** + * Set the line + * + * @param string $line + * + * @return $this + */ + public function setLine($line) + { + $this->line = (string) $line; + return $this; + } + + /** + * Get the line + * + * @return string + */ + public function getLine() + { + return $this->line; + } + + /** + * Set the line number + * + * @param int $lineno + * + * @return $this + */ + public function setLineno($lineno) + { + $this->lineno = (int) $lineno; + return $this; + } + + /** + * Set the match type + * + * @param int $matchType + * + * @return $this + */ + public function setMatchType($matchType) + { + $matchType = (int) $matchType; + if ($matchType !== static::MATCH_HEADER && $matchType !== static::MATCH_CONTENT) { + throw new UnexpectedValueException(); + } + $this->matchType = $matchType; + return $this; + } + + /** + * Get the match type + * + * @return int + */ + public function getMatchType() + { + return $this->matchType; + } + + /** + * Append a match + * + * @param string $match + * @param int $position + * + * @return $this + */ + public function appendMatch($match, $position) + { + $this->matches[(int) $position] = (string) $match; + return $this; + } + + /** + * Get the matches + * + * @return array + */ + public function getMatches() + { + return $this->matches; + } + + /** + * Set the view + * + * @param View $view + * + * @return $this + */ + public function setView(View $view) + { + $this->view = $view; + return $this; + } + + /** + * Get the view + * + * @return View + */ + public function getView() + { + if ($this->view === null) { + $this->view = Icinga::app()->getViewRenderer()->view; + } + return $this->view; + } + + /** + * Get the line having matches highlighted + * + * @return string + */ + public function highlight() + { + $highlighted = ''; + $offset = 0; + $matches = $this->getMatches(); + ksort($matches); + foreach ($matches as $position => $match) { + $highlighted .= $this->getView()->escape(substr($this->line, $offset, $position - $offset)) + . '<span class="' . static::HIGHLIGHT_CSS_CLASS .'">' + . $this->getView()->escape($match) + . '</span>'; + $offset = $position + strlen($match); + } + $highlighted .= $this->getView()->escape(substr($this->line, $offset)); + return $highlighted; + } + + /** + * Whether the match is empty + * + * @return bool + */ + public function isEmpty() + { + return empty($this->matches); + } +} |