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 @@
+
+ = $sectionRenderer->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
-= $this->partial('module/view.phtml', 'doc', array(
- 'toc' => $toc,
- 'html' => $html
-)); ?>
\ No newline at end of file
+
+= $this->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
-
-
-= $this->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
+= $this->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.
-
-
-= $html ?>
-
-
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 @@
+= $docName ?> = $this->translate('Documentation'); ?>
+
+ = $tocRenderer->render($this, $this->getHelper('Url')); ?>
+
+
+ = $sectionRenderer->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 @@
+
+
= $title ?>
+
+
+ = $tocRenderer->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.+)">(?P=tag)>)#u', $header, $match)
+ if ($header[0] === '<'
+ && preg_match('#(?:<(?Pa|span) (?:id|name)="(?P.+)">(?P=tag)>)\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('');
+ $this->docTree->prev();
+ $prev = $this->docTree->current();
+ if ($prev !== null) {
+ $prev = $prev->getValue();
+ $path = $zendUrlHelper->url(
+ array_merge(
+ $this->urlParams,
+ array(
+ 'chapterId' => $this->encodeUrlParam($prev->getChapterId())
+ )
+ ),
+ $this->url,
+ false,
+ false
+ );
+ $url = $view->url($path);
+ $url->setAnchor($this->encodeAnchor($prev->getId()));
+ $navigation[] = sprintf(
+ '- %s
',
+ $prev->isNoFollow() ? 'rel="nofollow" ' : '',
+ $url->getAbsoluteUrl(),
+ $view->escape($prev->getTitle())
+ );
+ $this->docTree->next();
+ $this->docTree->next();
+ } else {
+ $this->docTree->rewind();
+ $this->docTree->next();
+ }
+ $url = $view->url($this->tocUrl);
+ $navigation[] = sprintf(
+ '- %s
',
+ $url->getAbsoluteUrl(),
+ mt('doc', 'Index')
+ );
+ $next = $this->docTree->current();
+ if ($next !== null) {
+ $next = $next->getValue();
+ $path = $zendUrlHelper->url(
+ array_merge(
+ $this->urlParams,
+ array(
+ 'chapterId' => $this->encodeUrlParam($next->getChapterId())
+ )
+ ),
+ $this->url,
+ false,
+ false
+ );
+ $url = $view->url($path);
+ $url->setAnchor($this->encodeAnchor($next->getId()));
+ $navigation[] = sprintf(
+ '- %s
',
+ $next->isNoFollow() ? 'rel="nofollow" ' : '',
+ $url->getAbsoluteUrl(),
+ $view->escape($next->getTitle())
+ );
+ }
+ $navigation[] = '
';
+ $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);
+