diff --git a/.vagrant-puppet/files/etc/icingaweb/modules/doc/menu.ini b/.vagrant-puppet/files/etc/icingaweb/modules/doc/menu.ini new file mode 100644 index 000000000..86889b239 --- /dev/null +++ b/.vagrant-puppet/files/etc/icingaweb/modules/doc/menu.ini @@ -0,0 +1,5 @@ +[Documentation] +title = "Documentation" +icon = "img/icons/comment.png" +url = "doc" +priority = 80 diff --git a/.vagrant-puppet/manifests/default.pp b/.vagrant-puppet/manifests/default.pp index 5caf10452..86065d8fc 100644 --- a/.vagrant-puppet/manifests/default.pp +++ b/.vagrant-puppet/manifests/default.pp @@ -786,3 +786,15 @@ file { '/etc/bash_completion.d/icingacli': require => Exec['install bash-completion'] } +file { '/etc/icingaweb/modules/doc/': + ensure => 'directory', + owner => 'apache', + group => 'apache' +} + +file { '/etc/icingaweb/modules/doc/menu.ini': + source => 'puppet:////vagrant/.vagrant-puppet/files/etc/icingaweb/modules/doc/menu.ini', + owner => 'apache', + group => 'apache', +} + diff --git a/config/modules/doc/menu.ini b/config/modules/doc/menu.ini new file mode 100644 index 000000000..86889b239 --- /dev/null +++ b/config/modules/doc/menu.ini @@ -0,0 +1,5 @@ +[Documentation] +title = "Documentation" +icon = "img/icons/comment.png" +url = "doc" +priority = 80 diff --git a/library/Icinga/Application/Modules/Module.php b/library/Icinga/Application/Modules/Module.php index 8dd189097..3d5e3752e 100644 --- a/library/Icinga/Application/Modules/Module.php +++ b/library/Icinga/Application/Modules/Module.php @@ -5,6 +5,7 @@ namespace Icinga\Application\Modules; use Exception; +use Zend_Controller_Router_Route_Abstract; use Zend_Controller_Router_Route as Route; use Icinga\Application\ApplicationBootstrap; use Icinga\Application\Config; @@ -135,6 +136,16 @@ class Module */ private $app; + + /** + * Routes to add to the route chain + * + * @var array Array of name-route pairs + * + * @see addRoute() + */ + protected $routes = array(); + /** * Create a new module object * @@ -166,8 +177,7 @@ class Module */ public function register() { - $this->registerAutoloader() - ->registerWebIntegration(); + $this->registerAutoloader(); try { $this->launchRunScript(); } catch (Exception $e) { @@ -179,6 +189,7 @@ class Module ); return false; } + $this->registerWebIntegration(); return true; } @@ -658,24 +669,29 @@ class Module } /** - * Register routes for web access + * Add routes for static content and any route added via addRoute() to the route chain * - * @return self + * @return self + * @see addRoute() */ protected function registerRoutes() { - $this->app->getFrontController()->getRouter()->addRoute( + $router = $this->app->getFrontController()->getRouter(); + foreach ($this->routes as $name => $route) { + $router->addRoute($name, $route); + } + $router->addRoute( $this->name . '_jsprovider', new Route( 'js/' . $this->name . '/:file', array( 'controller' => 'static', 'action' =>'javascript', - 'module_name' => $this->name + 'module_name' => $this->name ) ) ); - $this->app->getFrontController()->getRouter()->addRoute( + $router->addRoute( $this->name . '_img', new Route( 'img/' . $this->name . '/:file', @@ -750,4 +766,19 @@ class Module return $this; } + + /** + * Add a route which will be added to the route chain + * + * @param string $name Name of the route + * @param Zend_Controller_Router_Route_Abstract $route Instance of the route + * + * @return self + * @see registerRoutes() + */ + protected function addRoute($name, Zend_Controller_Router_Route_Abstract $route) + { + $this->routes[$name] = $route; + return $this; + } } diff --git a/library/Icinga/Data/Identifiable.php b/library/Icinga/Data/Identifiable.php new file mode 100644 index 000000000..cfa727a1d --- /dev/null +++ b/library/Icinga/Data/Identifiable.php @@ -0,0 +1,18 @@ +value = $value; + } + + /** + * Get the node's value + * + * @return mixed + */ + public function getValue() + { + return $this->value; + } + + /** + * Create a new node from the given value and insert the node as the last child of this node + * + * @param mixed $value The node's value + * + * @return NodeInterface The appended node + */ + public function appendChild($value) + { + $child = new static($value); + $this->push($child); + return $child; + } + + /** + * Whether this node has child nodes + * + * @return bool + */ + public function hasChildren() + { + $current = $this->current(); + if ($current === null) { + $current = $this; + } + return ! $current->isEmpty(); + } + + /** + * Get the node's child nodes + * + * @return NodeInterface + */ + public function getChildren() + { + $current = $this->current(); + if ($current === null) { + $current = $this; + } + return $current; + } +} diff --git a/library/Icinga/Data/Tree/NodeInterface.php b/library/Icinga/Data/Tree/NodeInterface.php new file mode 100644 index 000000000..6953214dc --- /dev/null +++ b/library/Icinga/Data/Tree/NodeInterface.php @@ -0,0 +1,26 @@ +renderToc(Icinga::app()->getApplicationDir('/../doc'), 'Icinga Web 2', 'doc/icingaweb/chapter'); + } + + /** + * View a chapter of Icinga Web 2's documentation + * + * @throws Zend_Controller_Action_Exception If the required parameter 'chapterId' is missing + */ + public function chapterAction() + { + $chapterId = $this->getParam('chapterId'); + if ($chapterId === null) { + throw new Zend_Controller_Action_Exception( + $this->translate('Missing parameter \'chapterId\''), + 404 + ); + } + $this->renderChapter( + Icinga::app()->getApplicationDir('/../doc'), + $chapterId, + 'doc/icingaweb/toc', + 'doc/icingaweb/chapter' + ); + } + + /** + * View Icinga Web 2's documentation as PDF + */ + public function pdfAction() + { + $this->renderPdf(Icinga::app()->getApplicationDir('/../doc'), 'Icinga Web 2', 'doc/icingaweb/chapter'); + } +} diff --git a/modules/doc/application/controllers/IndexController.php b/modules/doc/application/controllers/IndexController.php index f46fdad87..c83cfabab 100644 --- a/modules/doc/application/controllers/IndexController.php +++ b/modules/doc/application/controllers/IndexController.php @@ -2,34 +2,9 @@ // {{{ICINGA_LICENSE_HEADER}}} // {{{ICINGA_LICENSE_HEADER}}} -use Icinga\Module\Doc\Controller as DocController; - -use Icinga\Module\Doc\DocParser; +use Icinga\Module\Doc\DocController; class Doc_IndexController extends DocController { - protected $parser; - - - public function init() - { - $module = null; - $this->parser = new DocParser($module); - } - - - public function tocAction() - { - // Temporary workaround - list($html, $toc) = $this->parser->getDocumentation(); - $this->view->toc = $toc; - } - - /** - * Display the application's documentation - */ - public function indexAction() - { - $this->populateView(); - } + public function indexAction() {} } diff --git a/modules/doc/application/controllers/ModuleController.php b/modules/doc/application/controllers/ModuleController.php index 41ba42db6..26dac8626 100644 --- a/modules/doc/application/controllers/ModuleController.php +++ b/modules/doc/application/controllers/ModuleController.php @@ -2,44 +2,131 @@ // {{{ICINGA_LICENSE_HEADER}}} // {{{ICINGA_LICENSE_HEADER}}} +use \Zend_Controller_Action_Exception; use Icinga\Application\Icinga; -use Icinga\Module\Doc\Controller as DocController; +use Icinga\Module\Doc\DocController; +use Icinga\Module\Doc\Exception\DocException; class Doc_ModuleController extends DocController { /** - * Display module documentations index + * List modules which are enabled and having the 'doc' directory */ public function indexAction() { - $this->view->enabledModules = Icinga::app()->getModuleManager()->listEnabledModules(); - } - - /** - * Display a module's documentation - */ - public function viewAction() - { - $this->populateView($this->getParam('name')); - } - - /** - * Provide run-time dispatching of module documentation - * - * @param string $methodName - * @param array $args - * - * @return mixed - */ - public function __call($methodName, $args) - { - // TODO(el): Setup routing to retrieve module name as param and point route to moduleAction - $moduleManager = Icinga::app()->getModuleManager(); - $moduleName = substr($methodName, 0, -6); // Strip 'Action' suffix - if (!$moduleManager->hasEnabled($moduleName)) { - // TODO(el): Throw a not found exception once the code has been moved to the moduleAction (see TODO above) - return parent::__call($methodName, $args); + $moduleManager = Icinga::app()->getModuleManager(); + $modules = array(); + foreach (Icinga::app()->getModuleManager()->listEnabledModules() as $enabledModule) { + $docDir = $moduleManager->getModuleDir($enabledModule, '/doc'); + if (is_dir($docDir)) { + $modules[] = $enabledModule; + } } - $this->_helper->redirector->gotoSimpleAndExit('view', null, null, array('name' => $moduleName)); + $this->view->modules = $modules; + } + + /** + * Assert that the given module is enabled + * + * @param $moduleName + * + * @throws Zend_Controller_Action_Exception If the required parameter 'moduleName' is empty or either if the + * given module is neither installed nor enabled + */ + protected function assertModuleEnabled($moduleName) + { + if (empty($moduleName)) { + throw new Zend_Controller_Action_Exception( + $this->translate('Missing parameter \'moduleName\''), + 404 + ); + } + $moduleManager = Icinga::app()->getModuleManager(); + if (! $moduleManager->hasInstalled($moduleName)) { + throw new Zend_Controller_Action_Exception( + $this->translate(sprintf('Module \'%s\' is not installed', $moduleName)), + 404 + ); + } + if (! $moduleManager->hasEnabled($moduleName)) { + throw new Zend_Controller_Action_Exception( + $this->translate(sprintf('Module \'%s\' is not enabled', $moduleName)), + 404 + ); + } + } + + /** + * View the toc of a module's documentation + * + * @see assertModuleEnabled() + */ + public function tocAction() + { + $moduleName = $this->getParam('moduleName'); + $this->assertModuleEnabled($moduleName); + $moduleManager = Icinga::app()->getModuleManager(); + try { + $this->renderToc( + $moduleManager->getModuleDir($moduleName, '/doc'), + $moduleName, + 'doc/module/chapter', + array('moduleName' => $moduleName) + ); + } catch (DocException $e) { + throw new Zend_Controller_Action_Exception($e->getMessage(), 404); + } + $this->view->moduleName = $moduleName; + } + + /** + * View a chapter of a module's documentation + * + * @throws Zend_Controller_Action_Exception If the required parameter 'chapterId' is missing or if an error in + * the documentation module's library occurs + * @see assertModuleEnabled() + */ + public function chapterAction() + { + $moduleName = $this->getParam('moduleName'); + $this->assertModuleEnabled($moduleName); + $chapterId = $this->getParam('chapterId'); + if ($chapterId === null) { + throw new Zend_Controller_Action_Exception( + $this->translate('Missing parameter \'chapterId\''), + 404 + ); + } + $moduleManager = Icinga::app()->getModuleManager(); + try { + $this->renderChapter( + $moduleManager->getModuleDir($moduleName, '/doc'), + $chapterId, + $this->_helper->url->url(array('moduleName' => $moduleName), 'doc/module/toc'), + 'doc/module/chapter', + array('moduleName' => $moduleName) + ); + } catch (DocException $e) { + throw new Zend_Controller_Action_Exception($e->getMessage(), 404); + } + $this->view->moduleName = $moduleName; + } + + /** + * View a module's documentation as PDF + * + * @see assertModuleEnabled() + */ + public function pdfAction() + { + $moduleName = $this->getParam('moduleName'); + $this->assertModuleEnabled($moduleName); + $moduleManager = Icinga::app()->getModuleManager(); + $this->renderPdf( + $moduleManager->getModuleDir($moduleName, '/doc'), + $moduleName, + 'doc/module/chapter', + array('moduleName' => $moduleName) + ); } } diff --git a/modules/doc/application/views/scripts/chapter.phtml b/modules/doc/application/views/scripts/chapter.phtml new file mode 100644 index 000000000..7657d69fb --- /dev/null +++ b/modules/doc/application/views/scripts/chapter.phtml @@ -0,0 +1,3 @@ +
+ render($this, $this->getHelper('Url')); ?> +
diff --git a/modules/doc/application/views/scripts/index/index.phtml b/modules/doc/application/views/scripts/index/index.phtml index a178cc155..e4218bee2 100644 --- a/modules/doc/application/views/scripts/index/index.phtml +++ b/modules/doc/application/views/scripts/index/index.phtml @@ -1,5 +1,6 @@ -

Icinga 2 Documentation

-partial('module/view.phtml', 'doc', array( - 'toc' => $toc, - 'html' => $html -)); ?> \ No newline at end of file +
+

translate('Available documentations'); ?>

+ diff --git a/modules/doc/application/views/scripts/index/toc.phtml b/modules/doc/application/views/scripts/index/toc.phtml deleted file mode 100644 index 9188e21ff..000000000 --- a/modules/doc/application/views/scripts/index/toc.phtml +++ /dev/null @@ -1,14 +0,0 @@ -
-

Module documentations

-
-
-partial( - 'layout/menu.phtml', - 'default', - array( - 'items' => $toc->getChildren(), - 'sub' => false, - 'url' => '' - ) -) ?> -
diff --git a/modules/doc/application/views/scripts/module/index.phtml b/modules/doc/application/views/scripts/module/index.phtml index 36f11e15e..cc184016f 100644 --- a/modules/doc/application/views/scripts/module/index.phtml +++ b/modules/doc/application/views/scripts/module/index.phtml @@ -1,6 +1,10 @@ -

Module documentations

+

translate('Module documentations'); ?>

diff --git a/modules/doc/application/views/scripts/module/view.phtml b/modules/doc/application/views/scripts/module/view.phtml deleted file mode 100644 index 291947ad7..000000000 --- a/modules/doc/application/views/scripts/module/view.phtml +++ /dev/null @@ -1,7 +0,0 @@ - -

No documentation available.

- -
- -
- diff --git a/modules/doc/application/views/scripts/pdf.phtml b/modules/doc/application/views/scripts/pdf.phtml new file mode 100644 index 000000000..72d77f3c0 --- /dev/null +++ b/modules/doc/application/views/scripts/pdf.phtml @@ -0,0 +1,7 @@ +

translate('Documentation'); ?>

+
+ render($this, $this->getHelper('Url')); ?> +
+
+ render($this, $this->getHelper('Url')); ?> +
diff --git a/modules/doc/application/views/scripts/toc.phtml b/modules/doc/application/views/scripts/toc.phtml new file mode 100644 index 000000000..ca6283d67 --- /dev/null +++ b/modules/doc/application/views/scripts/toc.phtml @@ -0,0 +1,6 @@ +
+

+
+
+ render($this, $this->getHelper('Url')); ?> +
diff --git a/modules/doc/library/Doc/Controller.php b/modules/doc/library/Doc/Controller.php deleted file mode 100644 index 2c5a07d49..000000000 --- a/modules/doc/library/Doc/Controller.php +++ /dev/null @@ -1,23 +0,0 @@ -getDocumentation(); - $this->view->html = $html; - $this->view->toc = $toc; - } -} diff --git a/modules/doc/library/Doc/DocController.php b/modules/doc/library/Doc/DocController.php new file mode 100644 index 000000000..66895bfb4 --- /dev/null +++ b/modules/doc/library/Doc/DocController.php @@ -0,0 +1,76 @@ +view->sectionRenderer = new SectionRenderer( + $parser->getDocTree(), + SectionRenderer::decodeUrlParam($chapterId), + $tocUrl, + $url, + $urlParams + ); + $this->view->title = $chapterId; + $this->_helper->viewRenderer('chapter', null, true); + } + + /** + * Render a toc + * + * @param string $path Path to the documentation + * @param string $name Name of the documentation + * @param string $url + * @param array $urlParams + */ + protected function renderToc($path, $name, $url, array $urlParams = array()) + { + $parser = new DocParser($path); + $this->view->tocRenderer = new TocRenderer($parser->getDocTree(), $url, $urlParams); + $name = ucfirst($name); + $this->view->docName = $name; + $this->view->title = $this->translate(sprintf('%s Documentation', $name)); + $this->_helper->viewRenderer('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); + $docTree = $parser->getDocTree(); + $this->view->tocRenderer = new TocRenderer($docTree, $url, $urlParams); + $this->view->sectionRenderer = new SectionRenderer( + $docTree, + null, + null, + $url, + $urlParams + ); + $this->view->docName = $name; + $this->_helper->viewRenderer('pdf', null, true); + $this->_request->setParam('format', 'pdf'); + } +} diff --git a/modules/doc/library/Doc/DocException.php b/modules/doc/library/Doc/DocException.php deleted file mode 100644 index cb7134045..000000000 --- a/modules/doc/library/Doc/DocException.php +++ /dev/null @@ -1,11 +0,0 @@ -fileInfo = $fileInfo; + } + + /** + * (non-PHPDoc) + * @see Countable::count() + */ + public function count() + { + return count($this->fileInfo); + } + + /** + * (non-PHPDoc) + * @see IteratorAggregate::getIterator() + */ + public function getIterator() + { + return new ArrayIterator($this->fileInfo); + } +} diff --git a/modules/doc/library/Doc/DocParser.php b/modules/doc/library/Doc/DocParser.php index a823ad17c..e0311e6ba 100644 --- a/modules/doc/library/Doc/DocParser.php +++ b/modules/doc/library/Doc/DocParser.php @@ -4,146 +4,65 @@ namespace Icinga\Module\Doc; -use RecursiveIteratorIterator; -use RecursiveDirectoryIterator; -use Parsedown; -use Icinga\Application\Icinga; -use Icinga\Web\Menu; -use Icinga\Web\Url; - -require_once 'IcingaVendor/Parsedown/Parsedown.php'; +use SplDoublyLinkedList; +use Icinga\Exception\NotReadableError; +use Icinga\Module\Doc\Exception\DocEmptyException; +use Icinga\Module\Doc\Exception\DocException; /** * Parser for documentation written in Markdown */ class DocParser { - protected $dir; - - protected $module; + /** + * Path to the documentation + * + * @var string + */ + protected $path; /** - * Create a new documentation parser for the given module or the application + * Iterator over documentation files * - * @param string $module - * - * @throws DocException + * @var DocIterator */ - public function __construct($module = null) - { - if ($module === null) { - $dir = Icinga::app()->getApplicationDir('/../doc'); - } else { - $mm = Icinga::app()->getModuleManager(); - if (!$mm->hasInstalled($module)) { - throw new DocException('Module is not installed'); - } - if (!$mm->hasEnabled($module)) { - throw new DocException('Module is not enabled'); - } - $dir = $mm->getModuleDir($module, '/doc'); - } - if (!is_dir($dir)) { - throw new DocException('Doc directory does not exist'); - } - $this->dir = $dir; - $this->module = $module; - } + protected $docIterator; /** - * Retrieve table of contents and HTML converted from markdown files sorted by filename + * Create a new documentation parser for the given path * - * @return array - * @throws DocException + * @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 + * @throws DocEmptyException If the documentation directory is empty */ - public function getDocumentation() + public function __construct($path) { - $iter = new RecursiveIteratorIterator( - new MarkdownFileIterator( - new RecursiveDirectoryIterator($this->dir) - ) - ); - $fileInfos = iterator_to_array($iter); - natcasesort($fileInfos); - $cat = array(); - $toc = array((object) array( - 'level' => 0, - 'item' => new Menu('doc') - )); - $itemPriority = 1; - foreach ($fileInfos as $fileInfo) { - try { - $fileObject = $fileInfo->openFile(); - } catch (RuntimeException $e) { - throw new DocException($e->getMessage()); - } - if ($fileObject->flock(LOCK_SH) === false) { - throw new DocException('Couldn\'t get the lock'); - } - $line = null; - while (!$fileObject->eof()) { - // Save last line for setext-style headers - $lastLine = $line; - $line = $fileObject->fgets(); - $header = $this->extractHeader($line, $lastLine); - if ($header !== null) { - list($header, $level) = $header; - $id = $this->extractHeaderId($header); - $attribs = array(); - $this->reduceToc($toc, $level); - if ($id === null) { - $path = array(); - foreach (array_slice($toc, 1) as $entry) { - $path[] = $entry->item->getTitle(); - } - $path[] = $header; - $id = implode('-', $path); - $attribs['rel'] = 'nofollow'; - } - $id = urlencode(str_replace('.', '.', strip_tags($id))); - $item = end($toc)->item->addChild( - $id, - array( - 'url' => Url::fromPath( - 'doc/module/view', - array( - 'name' => $this->module - ) - )->setAnchor($id), - 'title' => htmlspecialchars($header), - 'priority' => $itemPriority++, - 'attribs' => $attribs - ) - ); - $toc[] = ((object) array( - 'level' => $level, - 'item' => $item - )); - $line = '' . PHP_EOL . $line; - } - $cat[] = $line; - } - $fileObject->flock(LOCK_UN); + if (! is_dir($path)) { + throw new DocException( + mt('doc', sprintf('Documentation directory \'%s\' does not exist', $path)) + ); } - $html = Parsedown::instance()->parse(implode('', $cat)); - $html = preg_replace_callback( - '#
(.*?)\
#s', - array($this, 'highlight'), - $html - ); - return array($html, $toc[0]->item); - } - - /** - * Syntax highlighting for PHP code - * - * @param $match - * - * @return string - */ - protected function highlight($match) - { - return highlight_string(htmlspecialchars_decode($match[1]), true); + if (! is_readable($path)) { + throw new DocException( + mt('doc', sprintf('Documentation directory \'%s\' is not readable', $path)) + ); + } + $docIterator = new DocIterator($path); + if ($docIterator->count() === 0) { + throw new DocEmptyException( + mt( + 'doc', + sprintf( + 'Documentation directory \'%s\' does not contain any non-empty Markdown file (\'.md\' suffix)', + $path + ) + ) + ); + } + $this->path = $path; + $this->docIterator = $docIterator; } /** @@ -156,28 +75,28 @@ class DocParser */ protected function extractHeader($line, $lastLine) { - if (!$line) { + if (! $line) { return null; } $header = null; - if ($line && - $line[0] === '#' && - preg_match('/^#+/', $line, $match) === 1 + if ($line + && $line[0] === '#' + && preg_match('/^#+/', $line, $match) === 1 ) { - // Atx-style + // Atx $level = strlen($match[0]); $header = trim(substr($line, $level)); - if (!$header) { + if (! $header) { return null; } } elseif ( - $line && - ($line[0] === '=' || $line[0] === '-') && - preg_match('/^[=-]+\s*$/', $line, $match) === 1 + $line + && ($line[0] === '=' || $line[0] === '-') + && preg_match('/^[=-]+\s*$/', $line, $match) === 1 ) { // Setext $header = trim($lastLine); - if (!$header) { + if (! $header) { return null; } if ($match[0][0] === '=') { @@ -189,36 +108,67 @@ class DocParser if ($header === null) { return null; } - return array($header, $level); - } - - /** - * Extract header id in an a or a span tag - * - * @param string &$header - * - * @return id|null - */ - protected function extractHeaderId(&$header) - { - if ($header[0] === '<' && - preg_match('#(?:<(?Pa|span) id="(?P.+)">)#u', $header, $match) + if ($header[0] === '<' + && preg_match('#(?:<(?Pa|span) (?:id|name)="(?P.+)">)\s*#u', $header, $match) ) { $header = str_replace($match[0], '', $header); - return $match['id']; + $id = $match['id']; + } else { + $id = null; } - return null; + return array($header, $id, $level); } /** - * Reduce the toc to the given level + * Get the documentation tree * - * @param array &$toc - * @param int $level + * @return DocTree */ - protected function reduceToc(array &$toc, $level) { - while (end($toc)->level >= $level) { - array_pop($toc); + public function getDocTree() + { + $tree = new DocTree(); + $stack = new SplDoublyLinkedList(); + foreach ($this->docIterator as $fileInfo) { + /* @var $file \SplFileInfo */ + $file = $fileInfo->openFile(); + /* @var $file \SplFileObject */ + $lastLine = null; + foreach ($file as $line) { + $header = $this->extractHeader($line, $lastLine); + if ($header !== null) { + list($title, $id, $level) = $header; + while (! $stack->isEmpty() && $stack->top()->getLevel() >= $level) { + $stack->pop(); + } + if ($id === null) { + $path = array(); + foreach ($stack as $section) { + /* @var $section Section */ + $path[] = $section->getTitle(); + } + $path[] = $title; + $id = implode('-', $path); + $noFollow = true; + } else { + $noFollow = false; + } + if ($stack->isEmpty()) { + $chapterId = $id; + $section = new Section($id, $title, $level, $noFollow, $chapterId); + $tree->addRoot($section); + } else { + $chapterId = $stack->bottom()->getId(); + $section = new Section($id, $title, $level, $noFollow, $chapterId); + $tree->addChild($section, $stack->top()); + } + $stack->push($section); + } else { + $stack->top()->appendContent($line); + } + // Save last line for setext-style headers + $lastLine = $line; + } } + return $tree; } } diff --git a/modules/doc/library/Doc/DocTree.php b/modules/doc/library/Doc/DocTree.php new file mode 100644 index 000000000..4223d8e99 --- /dev/null +++ b/modules/doc/library/Doc/DocTree.php @@ -0,0 +1,80 @@ +getId(); + if (isset($this->nodes[$rootId])) { + $rootId = uniqid($rootId); +// throw new LogicException( +// sprintf('Can\'t add root node: a root node with the id \'%s\' already exists', $rootId) +// ); + } + $this->nodes[$rootId] = $this->appendChild($root); + } + + /** + * Append a child node to a parent node + * + * @param Identifiable $child + * @param Identifiable $parent + * + * @throws LogicException If the the tree does not contain the parent node + */ + public function addChild(Identifiable $child, Identifiable $parent) + { + $childId = $child->getId(); + $parentId = $parent->getId(); + if (isset($this->nodes[$childId])) { + $childId = uniqid($childId); +// throw new LogicException( +// sprintf('Can\'t add child node: a child node with the id \'%s\' already exists', $childId) +// ); + } + if (! isset($this->nodes[$parentId])) { + throw new LogicException( + mt('doc', sprintf('Can\'t add child node: there\'s no parent node having the id \'%s\'', $parentId)) + ); + } + $this->nodes[$childId] = $this->nodes[$parentId]->appendChild($child); + } + + /** + * Get a node + * + * @param mixed $id + * + * @return Node|null + */ + public function getNode($id) + { + if (! isset($this->nodes[$id])) { + return null; + } + return $this->nodes[$id]; + } +} diff --git a/modules/doc/library/Doc/Exception/ChapterNotFoundException.php b/modules/doc/library/Doc/Exception/ChapterNotFoundException.php new file mode 100644 index 000000000..cd048a162 --- /dev/null +++ b/modules/doc/library/Doc/Exception/ChapterNotFoundException.php @@ -0,0 +1,10 @@ +getInnerIterator()->current(); - if (!$current->isFile()) { + /* @var $current \SplFileInfo */ + if (! $current->isFile()) { return false; } $filename = $current->getFilename(); diff --git a/modules/doc/library/Doc/NonEmptyFileIterator.php b/modules/doc/library/Doc/NonEmptyFileIterator.php new file mode 100644 index 000000000..71bf5acfa --- /dev/null +++ b/modules/doc/library/Doc/NonEmptyFileIterator.php @@ -0,0 +1,31 @@ +getInnerIterator()->current(); + /* @var $current \SplFileInfo */ + if (! $current->isFile() + || $current->getSize() === 0 + ) { + return false; + } + return true; + } +} diff --git a/modules/doc/library/Doc/Renderer.php b/modules/doc/library/Doc/Renderer.php new file mode 100644 index 000000000..0aebb89b9 --- /dev/null +++ b/modules/doc/library/Doc/Renderer.php @@ -0,0 +1,75 @@ +id = $id; + $this->title = $title; + $this->level = $level; + $this->noFollow = $noFollow; + $this->chapterId= $chapterId; + } + + /** + * Get the ID of the section + * + * @return string + */ + public function getId() + { + return $this->id; + } + + /** + * Get the title of the section + * + * @return string + */ + public function getTitle() + { + return $this->title; + } + + /** + * Get the header level + * + * @return int + */ + public function getLevel() + { + return $this->level; + } + + /** + * Whether to instruct search engines to not index the link to the section + * + * @return bool + */ + public function isNoFollow() + { + return $this->noFollow; + } + + /** + * The ID of the chapter the section is part of + * + * @return string + */ + public function getChapterId() + { + return $this->chapterId; + } + + /** + * 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; + } +} diff --git a/modules/doc/library/Doc/SectionFilterIterator.php b/modules/doc/library/Doc/SectionFilterIterator.php new file mode 100644 index 000000000..e20d80359 --- /dev/null +++ b/modules/doc/library/Doc/SectionFilterIterator.php @@ -0,0 +1,68 @@ +chapterId = $chapterId; + } + + /** + * 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() + { + $section = $this->getInnerIterator()->current()->getValue(); + /* @var $section \Icinga\Module\Doc\Section */ + if ($section->getChapterId() === $this->chapterId) { + return true; + } + return false; + } + + /** + * (non-PHPDoc) + * @see RecursiveFilterIterator::getChildren() + */ + public function getChildren() + { + return new static($this->getInnerIterator()->getChildren(), $this->chapterId); + } + + /** + * (non-PHPDoc) + * @see Countable::count() + */ + public function count() + { + return iterator_count($this); + } +} diff --git a/modules/doc/library/Doc/SectionRenderer.php b/modules/doc/library/Doc/SectionRenderer.php new file mode 100644 index 000000000..6281b6d76 --- /dev/null +++ b/modules/doc/library/Doc/SectionRenderer.php @@ -0,0 +1,292 @@ +docTree = $docTree; + $this->view = $view; + $this->zendUrlHelper = $zendUrlHelper; + $this->url = $url; + $this->urlParams = $urlParams; + } + + public function render($match) + { + $node = $this->docTree->getNode(Renderer::decodeAnchor($match['fragment'])); + /* @var $node \Icinga\Data\Tree\Node */ + if ($node === null) { + return $match[0]; + } + $section = $node->getValue(); + /* @var $section \Icinga\Module\Doc\Section */ + $path = $this->zendUrlHelper->url( + array_merge( + $this->urlParams, + array( + 'chapterId' => SectionRenderer::encodeUrlParam($section->getChapterId()) + ) + ), + $this->url, + false, + false + ); + $url = $this->view->url($path); + $url->setAnchor(SectionRenderer::encodeAnchor($section->getId())); + return sprintf( + 'isNoFollow() ? 'rel="nofollow" ' : '', + $url->getAbsoluteUrl() + ); + } +} + +/** + * Section renderer + */ +class SectionRenderer extends Renderer +{ + /** + * The documentation tree + * + * @var DocTree + */ + protected $docTree; + + protected $tocUrl; + + /** + * The URL to replace links with + * + * @var string + */ + protected $url; + + /** + * Additional URL parameters + * + * @var array + */ + protected $urlParams; + + /** + * Parsedown instance + * + * @var Parsedown + */ + protected $parsedown; + + /** + * Content + * + * @var array + */ + protected $content = array(); + + /** + * Create a new section renderer + * + * @param DocTree $docTree The documentation tree + * @param string|null $chapterId If not null, the chapter ID to filter for + * @param string $tocUrl + * @param string $url The URL to replace links with + * @param array $urlParams Additional URL parameters + * + * @throws ChapterNotFoundException If the chapter to filter for was not found + */ + public function __construct(DocTree $docTree, $chapterId, $tocUrl, $url, array $urlParams) + { + if ($chapterId !== null) { + $filter = new SectionFilterIterator($docTree, $chapterId); + if ($filter->count() === 0) { + throw new ChapterNotFoundException( + mt('doc', sprintf('Chapter \'%s\' not found', $chapterId)) + ); + } + parent::__construct( + $filter, + RecursiveIteratorIterator::SELF_FIRST + ); + } else { + parent::__construct($docTree, RecursiveIteratorIterator::SELF_FIRST); + } + $this->docTree = $docTree; + $this->tocUrl = $tocUrl; + $this->url = $url; + $this->urlParams = array_map(array($this, 'encodeUrlParam'), $urlParams); + $this->parsedown = Parsedown::instance(); + } + + /** + * Syntax highlighting for PHP code + * + * @param $match + * + * @return string + */ + protected function highlightPhp($match) + { + return '
' . highlight_string(htmlspecialchars_decode($match[1]), true) . '
'; + } + + /** + * 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 $img \DOMElement */ + $img->setAttribute('src', Url::fromPath($img->getAttribute('src'))->getAbsoluteUrl()); + return substr_replace($doc->saveXML($img), '', -2, 1); // Replace '/>' with '>' + } + + /** + * Render the section + * + * @param View $view + * @param Zend_View_Helper_Url $zendUrlHelper + * @param bool $renderNavigation + * + * @return string + */ + public function render(View $view, Zend_View_Helper_Url $zendUrlHelper, $renderNavigation = true) + { + $callback = new Callback($this->docTree, $view, $zendUrlHelper, $this->url, $this->urlParams); + $content = array(); + foreach ($this as $node) { + $section = $node->getValue(); + /* @var $section \Icinga\Module\Doc\Section */ + $content[] = sprintf( + '
%3$s', + Renderer::encodeAnchor($section->getId()), + $section->getLevel(), + $view->escape($section->getTitle()) + ); + $html = preg_replace_callback( + '#
(.*?)
#s', + array($this, 'highlightPhp'), + $this->parsedown->text(implode('', $section->getContent())) + ); + $html = preg_replace_callback( + '/]+>/', + array($this, 'replaceImg'), + $html + ); + $content[] = preg_replace_callback( + '/[^>]*?\s+)?href="#(?P[^"]+)"/', + array($callback, 'render'), + $html + ); + } + if ($renderNavigation) { + foreach ($this->docTree as $chapter) { + if ($chapter->getValue()->getId() === $section->getChapterId()) { + $navigation = array(''; + $content = array_merge($navigation, $content, $navigation); + break; + } + } + } + return implode("\n", $content); + } +} diff --git a/modules/doc/library/Doc/TocRenderer.php b/modules/doc/library/Doc/TocRenderer.php new file mode 100644 index 000000000..4061e80e3 --- /dev/null +++ b/modules/doc/library/Doc/TocRenderer.php @@ -0,0 +1,109 @@ +url = $url; + $this->urlParams = array_map(array($this, 'encodeUrlParam'), $urlParams); + } + + public function beginIteration() + { + $this->content[] = ''; + } + + public function beginChildren() + { + $this->content[] = '
    '; + } + + public function endChildren() + { + $this->content[] = '
'; + } + + /** + * Render the toc + * + * @param View $view + * @param Zend_View_Helper_Url $zendUrlHelper + * + * @return string + */ + public function render(View $view, Zend_View_Helper_Url $zendUrlHelper) + { + foreach ($this as $node) { + $section = $node->getValue(); + /* @var $section \Icinga\Module\Doc\Section */ + $path = $zendUrlHelper->url( + array_merge( + $this->urlParams, + array( + 'chapterId' => $this->encodeUrlParam($section->getChapterId()) + ) + ), + $this->url, + false, + false + ); + $url = $view->url($path); + $url->setAnchor($this->encodeAnchor($section->getId())); + $this->content[] = sprintf( + '
  • %s', + $section->isNoFollow() ? 'rel="nofollow" ' : '', + $url->getAbsoluteUrl(), + $view->escape($section->getTitle()) + ); + if (! $this->getInnerIterator()->current()->hasChildren()) { + $this->content[] = '
  • '; + } + } + return implode("\n", $this->content); + } +} diff --git a/modules/doc/public/css/module.less b/modules/doc/public/css/module.less new file mode 100644 index 000000000..d6d0d2a94 --- /dev/null +++ b/modules/doc/public/css/module.less @@ -0,0 +1,62 @@ +// W3C Recommendation (except h4) +h1 { font-size: 2em !important; } +h2 { font-size: 1.5em !important; } +h3 { font-size: 1.17em !important; } +h4 { font-size: 1em !important; } +h5 { font-size: .83em !important; } +h6 { font-size: .75em !important; } + +div.chapter { + padding-left: 5px; +} + +table th { + text-align: left; +} + +table th, +table td { + border: solid 1px lightgray; + padding-left: 5px; + padding-right: 5px; +} + +code { + width: 100%; + overflow-x: auto; + padding: 0.2em; + display: inline; +} + +pre > code { + display: inline-block; +} + +div.chapter > ul.navigation { + margin: 0; + padding: 0.4em; + text-align: center; + background-color: #888; + + li { + list-style: none; + display: inline; + margin: 0.2em; + padding: 0; + + a { + color: #fff; + text-decoration: none; + } + + &.prev { + padding-right: 0.6em; + border-right: 2px solid #fff; + } + + &.next { + padding-left: 0.6em; + border-left: 2px solid #fff; + } + } +} diff --git a/modules/doc/run.php b/modules/doc/run.php new file mode 100644 index 000000000..7392e4c22 --- /dev/null +++ b/modules/doc/run.php @@ -0,0 +1,50 @@ +isCli()) { + return; +} + +$docModuleChapter = new Zend_Controller_Router_Route( + 'doc/module/:moduleName/chapter/:chapterId', + array( + 'controller' => 'module', + 'action' => 'chapter', + 'module' => 'doc' + ) +); + +$docIcingaWebChapter = new Zend_Controller_Router_Route( + 'doc/icingaweb/chapter/:chapterId', + array( + 'controller' => 'icingaweb', + 'action' => 'chapter', + 'module' => 'doc' + ) +); + +$docModuleToc = new Zend_Controller_Router_Route( + 'doc/module/:moduleName/toc', + array( + 'controller' => 'module', + 'action' => 'toc', + 'module' => 'doc' + ) +); + +$docModulePdf = new Zend_Controller_Router_Route( + 'doc/module/:moduleName/pdf', + array( + 'controller' => 'module', + 'action' => 'pdf', + 'module' => 'doc' + ) +); + +$this->addRoute('doc/module/chapter', $docModuleChapter); +$this->addRoute('doc/icingaweb/chapter', $docIcingaWebChapter); +$this->addRoute('doc/module/toc', $docModuleToc); +$this->addRoute('doc/module/pdf', $docModulePdf); +