summaryrefslogtreecommitdiffstats
path: root/modules/doc
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-13 11:46:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-13 11:46:43 +0000
commit3e02d5aff85babc3ffbfcf52313f2108e313aa23 (patch)
treeb01f3923360c20a6a504aff42d45670c58af3ec5 /modules/doc
parentInitial commit. (diff)
downloadicingaweb2-3e02d5aff85babc3ffbfcf52313f2108e313aa23.tar.xz
icingaweb2-3e02d5aff85babc3ffbfcf52313f2108e313aa23.zip
Adding upstream version 2.12.1.upstream/2.12.1upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--modules/doc/application/controllers/IcingawebController.php62
-rw-r--r--modules/doc/application/controllers/IndexController.php27
-rw-r--r--modules/doc/application/controllers/ModuleController.php206
-rw-r--r--modules/doc/application/controllers/SearchController.php97
-rw-r--r--modules/doc/application/controllers/StyleController.php41
-rw-r--r--modules/doc/application/views/scripts/chapter.phtml6
-rw-r--r--modules/doc/application/views/scripts/index/index.phtml19
-rw-r--r--modules/doc/application/views/scripts/module/index.phtml19
-rw-r--r--modules/doc/application/views/scripts/pdf.phtml5
-rw-r--r--modules/doc/application/views/scripts/search/index.phtml8
-rw-r--r--modules/doc/application/views/scripts/style/font.phtml14
-rw-r--r--modules/doc/application/views/scripts/style/guide.phtml112
-rw-r--r--modules/doc/application/views/scripts/toc.phtml6
-rw-r--r--modules/doc/configuration.php24
-rw-r--r--modules/doc/doc/01-About.md6
-rw-r--r--modules/doc/doc/02-Installation.md15
-rw-r--r--modules/doc/doc/03-Module-Documentation.md87
-rw-r--r--modules/doc/doc/img/markdown.pngbin0 -> 2180 bytes
-rw-r--r--modules/doc/library/Doc/DocController.php116
-rw-r--r--modules/doc/library/Doc/DocParser.php235
-rw-r--r--modules/doc/library/Doc/DocSection.php159
-rw-r--r--modules/doc/library/Doc/DocSectionFilterIterator.php73
-rw-r--r--modules/doc/library/Doc/Exception/ChapterNotFoundException.php11
-rw-r--r--modules/doc/library/Doc/Exception/DocException.php13
-rw-r--r--modules/doc/library/Doc/Renderer/DocRenderer.php208
-rw-r--r--modules/doc/library/Doc/Renderer/DocSearchRenderer.php131
-rw-r--r--modules/doc/library/Doc/Renderer/DocSectionRenderer.php345
-rw-r--r--modules/doc/library/Doc/Renderer/DocTocRenderer.php117
-rw-r--r--modules/doc/library/Doc/Search/DocSearch.php95
-rw-r--r--modules/doc/library/Doc/Search/DocSearchIterator.php114
-rw-r--r--modules/doc/library/Doc/Search/DocSearchMatch.php215
-rw-r--r--modules/doc/module.info4
-rw-r--r--modules/doc/public/css/module.less120
-rw-r--r--modules/doc/public/js/module.js30
-rw-r--r--modules/doc/run.php64
35 files changed, 2804 insertions, 0 deletions
diff --git a/modules/doc/application/controllers/IcingawebController.php b/modules/doc/application/controllers/IcingawebController.php
new file mode 100644
index 0000000..f6ff738
--- /dev/null
+++ b/modules/doc/application/controllers/IcingawebController.php
@@ -0,0 +1,62 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Module\Doc\Controllers;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Doc\DocController;
+
+class IcingawebController extends DocController
+{
+ /**
+ * Get the path to Icinga Web 2's documentation
+ *
+ * @return ?string
+ *
+ * @throws \Icinga\Exception\Http\HttpNotFoundException If Icinga Web 2's documentation is not available
+ */
+ protected function getPath()
+ {
+ $path = Icinga::app()->getBaseDir('doc');
+ if (is_dir($path)) {
+ return $path;
+ }
+ if (($path = $this->Config()->get('documentation', 'icingaweb2')) !== null) {
+ if (is_dir($path)) {
+ return $path;
+ }
+ }
+ $this->httpNotFound($this->translate('Documentation for Icinga Web 2 is not available'));
+ }
+
+ /**
+ * View the toc of Icinga Web 2's documentation
+ */
+ public function tocAction()
+ {
+ $this->renderToc($this->getPath(), 'Icinga Web 2', 'doc/icingaweb/chapter');
+ }
+
+ /**
+ * View a chapter of Icinga Web 2's documentation
+ *
+ * @throws \Icinga\Exception\MissingParameterException If the required parameter 'chapter' is missing
+ */
+ public function chapterAction()
+ {
+ $chapter = $this->params->getRequired('chapter');
+ $this->renderChapter(
+ $this->getPath(),
+ $chapter,
+ 'doc/icingaweb/chapter'
+ );
+ }
+
+ /**
+ * View Icinga Web 2's documentation as PDF
+ */
+ public function pdfAction()
+ {
+ $this->renderPdf($this->getPath(), 'Icinga Web 2', 'doc/icingaweb/chapter');
+ }
+}
diff --git a/modules/doc/application/controllers/IndexController.php b/modules/doc/application/controllers/IndexController.php
new file mode 100644
index 0000000..3ff5aa1
--- /dev/null
+++ b/modules/doc/application/controllers/IndexController.php
@@ -0,0 +1,27 @@
+<?php
+/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Module\Doc\Controllers;
+
+use Icinga\Module\Doc\DocController;
+use Icinga\Web\Url;
+
+/**
+ * Documentation module index
+ */
+class IndexController extends DocController
+{
+ /**
+ * Documentation module landing page
+ *
+ * Lists documentation links
+ */
+ public function indexAction()
+ {
+ $this->getTabs()->add('documentation', array(
+ 'active' => true,
+ 'title' => $this->translate('Documentation', 'Tab title'),
+ 'url' => Url::fromRequest()
+ ));
+ }
+}
diff --git a/modules/doc/application/controllers/ModuleController.php b/modules/doc/application/controllers/ModuleController.php
new file mode 100644
index 0000000..9e94a61
--- /dev/null
+++ b/modules/doc/application/controllers/ModuleController.php
@@ -0,0 +1,206 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Module\Doc\Controllers;
+
+use finfo;
+use SplFileInfo;
+use Icinga\Application\Icinga;
+use Icinga\Module\Doc\DocController;
+use Icinga\Module\Doc\Exception\DocException;
+use Icinga\Web\Url;
+
+class ModuleController extends DocController
+{
+ /**
+ * Get the path to a module documentation
+ *
+ * @param string $module The name of the module
+ * @param string $default The default path
+ * @param bool $suppressErrors Whether to not throw an exception if the module documentation is not available
+ *
+ * @return string|null Path to the documentation or null if the module documentation is not available
+ * and errors are suppressed
+ *
+ * @throws \Icinga\Exception\Http\HttpNotFoundException If the module documentation is not available and errors
+ * are not suppressed
+ */
+ protected function getPath($module, $default, $suppressErrors = false)
+ {
+ if (is_dir($default)) {
+ return $default;
+ }
+ if (($path = $this->Config()->get('documentation', 'modules')) !== null) {
+ $path = str_replace('{module}', $module, $path);
+ if (is_dir($path)) {
+ return $path;
+ }
+ }
+ if ($suppressErrors) {
+ return null;
+ }
+ $this->httpNotFound($this->translate('Documentation for module \'%s\' is not available'), $module);
+ }
+
+ /**
+ * List modules which are enabled and having the 'doc' directory
+ */
+ public function indexAction()
+ {
+ $moduleManager = Icinga::app()->getModuleManager();
+ $modules = array();
+ foreach ($moduleManager->listInstalledModules() as $module) {
+ $path = $this->getPath($module, $moduleManager->getModuleDir($module, '/doc'), true);
+ if ($path !== null) {
+ $modules[] = $moduleManager->getModule($module, false);
+ }
+ }
+ $this->view->modules = $modules;
+ $this->getTabs()->add('module-documentation', array(
+ 'active' => true,
+ 'title' => $this->translate('Module Documentation', 'Tab title'),
+ 'url' => Url::fromRequest()
+ ));
+ }
+
+ /**
+ * Assert that the given module is installed
+ *
+ * @param string $moduleName
+ *
+ * @throws \Icinga\Exception\Http\HttpNotFoundException If the given module is not installed
+ */
+ protected function assertModuleInstalled($moduleName)
+ {
+ $moduleManager = Icinga::app()->getModuleManager();
+ if (! $moduleManager->hasInstalled($moduleName)) {
+ $this->httpNotFound($this->translate('Module \'%s\' is not installed'), $moduleName);
+ }
+ }
+
+ /**
+ * View the toc of a module's documentation
+ *
+ * @throws \Icinga\Exception\MissingParameterException If the required parameter 'moduleName' is empty
+ * @throws \Icinga\Exception\Http\HttpNotFoundException If the given module is not installed
+ * @see assertModuleInstalled()
+ */
+ public function tocAction()
+ {
+ $module = $this->params->getRequired('moduleName');
+ $this->assertModuleInstalled($module);
+ $moduleManager = Icinga::app()->getModuleManager();
+ $name = $moduleManager->getModule($module, false)->getTitle();
+ try {
+ $this->renderToc(
+ $this->getPath($module, Icinga::app()->getModuleManager()->getModuleDir($module, '/doc')),
+ $name,
+ 'doc/module/chapter',
+ array('moduleName' => $module)
+ );
+ } catch (DocException $e) {
+ $this->httpNotFound($e->getMessage());
+ }
+ }
+
+ /**
+ * View a chapter of a module's documentation
+ *
+ * @throws \Icinga\Exception\MissingParameterException If one of the required parameters 'moduleName' and
+ * 'chapter' is empty
+ * @throws \Icinga\Exception\Http\HttpNotFoundException If the given module is not installed
+ * @see assertModuleInstalled()
+ */
+ public function chapterAction()
+ {
+ $module = $this->params->getRequired('moduleName');
+ $this->assertModuleInstalled($module);
+ $chapter = $this->params->getRequired('chapter');
+ $this->view->moduleName = $module;
+ try {
+ $this->renderChapter(
+ $this->getPath($module, Icinga::app()->getModuleManager()->getModuleDir($module, '/doc')),
+ $chapter,
+ 'doc/module/chapter',
+ 'doc/module/img',
+ array('moduleName' => $module)
+ );
+ } catch (DocException $e) {
+ $this->httpNotFound($e->getMessage());
+ }
+ }
+
+ /**
+ * Deliver images
+ */
+ public function imageAction()
+ {
+ $module = $this->params->getRequired('moduleName');
+ $image = $this->params->getRequired('image');
+ $docPath = $this->getPath($module, Icinga::app()->getModuleManager()->getModuleDir($module, '/doc'));
+ $imagePath = realpath($docPath . '/' . $image);
+ if ($imagePath === false || substr($imagePath, 0, strlen($docPath)) !== $docPath) {
+ $this->httpNotFound('%s does not exist', $image);
+ }
+
+ $this->_helper->viewRenderer->setNoRender(true);
+ $this->_helper->layout()->disableLayout();
+
+ $imageInfo = new SplFileInfo($imagePath);
+
+ $etag = md5($imageInfo->getMTime() . $imagePath);
+ $lastModified = gmdate('D, d M Y H:i:s T', $imageInfo->getMTime());
+ $match = false;
+
+ if (isset($_SERVER['HTTP_IF_NONE_MATCH'])) {
+ $ifNoneMatch = explode(', ', stripslashes($_SERVER['HTTP_IF_NONE_MATCH']));
+ foreach ($ifNoneMatch as $tag) {
+ if ($tag === $etag) {
+ $match = true;
+ break;
+ }
+ }
+ } elseif (isset($_SERVER['HTTP_IF_MODIFIED_SINCE'])) {
+ $lastModifiedSince = stripslashes($_SERVER['HTTP_IF_MODIFIED_SINCE']);
+ if ($lastModifiedSince === $lastModified) {
+ $match = true;
+ }
+ }
+
+ $this->getResponse()
+ ->setHeader('ETag', $etag)
+ ->setHeader('Cache-Control', 'no-transform,public,max-age=3600,must-revalidate', true);
+
+ if ($match) {
+ $this->getResponse()->setHttpResponseCode(304);
+ } else {
+ $finfo = new finfo();
+ $this->getResponse()
+ ->setHeader('Content-Type', $finfo->file($imagePath, FILEINFO_MIME_TYPE))
+ ->setHeader('Content-Length', $imageInfo->getSize())
+ ->sendHeaders();
+
+ ob_end_clean();
+ readfile($imagePath);
+ }
+ }
+
+ /**
+ * View a module's documentation as PDF
+ *
+ * @throws \Icinga\Exception\MissingParameterException If the required parameter 'moduleName' is empty
+ * @throws \Icinga\Exception\Http\HttpNotFoundException If the given module is not installed
+ * @see assertModuleInstalled()
+ */
+ public function pdfAction()
+ {
+ $module = $this->params->getRequired('moduleName');
+ $this->assertModuleInstalled($module);
+ $this->renderPdf(
+ $this->getPath($module, Icinga::app()->getModuleManager()->getModuleDir($module, '/doc')),
+ $module,
+ 'doc/module/chapter',
+ array('moduleName' => $module)
+ );
+ }
+}
diff --git a/modules/doc/application/controllers/SearchController.php b/modules/doc/application/controllers/SearchController.php
new file mode 100644
index 0000000..6d26f52
--- /dev/null
+++ b/modules/doc/application/controllers/SearchController.php
@@ -0,0 +1,97 @@
+<?php
+/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Module\Doc\Controllers;
+
+use Icinga\Application\Icinga;
+use Icinga\Module\Doc\DocController;
+use Icinga\Module\Doc\DocParser;
+use Icinga\Module\Doc\Exception\DocException;
+use Icinga\Module\Doc\Renderer\DocSearchRenderer;
+use Icinga\Module\Doc\Search\DocSearch;
+use Icinga\Module\Doc\Search\DocSearchIterator;
+
+class SearchController extends DocController
+{
+ /**
+ * Render search
+ */
+ public function indexAction()
+ {
+ $parser = new DocParser($this->getWebPath());
+ $search = new DocSearchRenderer(
+ new DocSearchIterator(
+ $parser->getDocTree()->getIterator(),
+ new DocSearch($this->params->get('q'))
+ )
+ );
+ $search->setUrl('doc/icingaweb/chapter');
+ if (strlen($this->params->get('q')) < 3) {
+ $this->view->searches = array();
+ return;
+ }
+ $searches = array(
+ 'Icinga Web 2' => $search
+ );
+ foreach (Icinga::app()->getModuleManager()->listEnabledModules() as $module) {
+ if (($path = $this->getModulePath($module)) !== null) {
+ try {
+ $parser = new DocParser($path);
+ $search = new DocSearchRenderer(
+ new DocSearchIterator(
+ $parser->getDocTree()->getIterator(),
+ new DocSearch($this->params->get('q'))
+ )
+ );
+ } catch (DocException $e) {
+ continue;
+ }
+ $search
+ ->setUrl('doc/module/chapter')
+ ->setUrlParams(array('moduleName' => $module));
+ $searches[$module] = $search;
+ }
+ }
+ $this->view->searches = $searches;
+ }
+
+ /**
+ * Get the path to a module's documentation
+ *
+ * @param string $module
+ *
+ * @return string|null
+ */
+ protected function getModulePath($module)
+ {
+ if (is_dir(($path = Icinga::app()->getModuleManager()->getModuleDir($module, '/doc')))) {
+ return $path;
+ }
+ if (($path = $this->Config()->get('documentation', 'modules')) !== null) {
+ $path = str_replace('{module}', $module, $path);
+ if (is_dir($path)) {
+ return $path;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Get the path to Icinga Web 2's documentation
+ *
+ * @return ?string
+ */
+ protected function getWebPath()
+ {
+ $path = Icinga::app()->getBaseDir('doc');
+ if (is_dir($path)) {
+ return $path;
+ }
+ if (($path = $this->Config()->get('documentation', 'icingaweb2')) !== null) {
+ if (is_dir($path)) {
+ return $path;
+ }
+ }
+ $this->httpNotFound($this->translate('Documentation for Icinga Web 2 is not available'));
+ }
+}
diff --git a/modules/doc/application/controllers/StyleController.php b/modules/doc/application/controllers/StyleController.php
new file mode 100644
index 0000000..5890367
--- /dev/null
+++ b/modules/doc/application/controllers/StyleController.php
@@ -0,0 +1,41 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+namespace Icinga\Module\Doc\Controllers;
+
+use Icinga\Application\Icinga;
+use Icinga\Web\Controller;
+use Icinga\Web\Widget;
+
+class StyleController extends Controller
+{
+ public function guideAction()
+ {
+ $this->view->tabs = $this->tabs()->activate('guide');
+ }
+
+ public function fontAction()
+ {
+ $this->view->tabs = $this->tabs()->activate('font');
+ $confFile = Icinga::app()->getApplicationDir('fonts/fontello-ifont/config.json');
+ $this->view->font = json_decode(file_get_contents($confFile));
+ }
+
+ protected function tabs()
+ {
+ return Widget::create('tabs')->add(
+ 'guide',
+ array(
+ 'label' => $this->translate('Style Guide'),
+ 'url' => 'doc/style/guide'
+ )
+ )->add(
+ 'font',
+ array(
+ 'label' => $this->translate('Icons'),
+ 'title' => $this->translate('List all available icons'),
+ 'url' => 'doc/style/font'
+ )
+ );
+ }
+}
diff --git a/modules/doc/application/views/scripts/chapter.phtml b/modules/doc/application/views/scripts/chapter.phtml
new file mode 100644
index 0000000..8cd4f6e
--- /dev/null
+++ b/modules/doc/application/views/scripts/chapter.phtml
@@ -0,0 +1,6 @@
+<div class="controls">
+ <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $this->tabs ?>
+</div>
+<div class="content chapter">
+ <?= /** @var \Icinga\Module\Doc\Renderer\DocSectionRenderer $section */ $section ?>
+</div>
diff --git a/modules/doc/application/views/scripts/index/index.phtml b/modules/doc/application/views/scripts/index/index.phtml
new file mode 100644
index 0000000..9bf745a
--- /dev/null
+++ b/modules/doc/application/views/scripts/index/index.phtml
@@ -0,0 +1,19 @@
+<div class="controls">
+ <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?>
+</div>
+<div class="content">
+ <ul>
+ <li><?= $this->qlink(
+ 'Icinga Web 2',
+ 'doc/icingaweb/toc',
+ null,
+ array('title' => $this->translate('Show the documentation\'s table of contents for Icinga Web 2'))
+ ) ?></li>
+ <li><?= $this->qlink(
+ $this->translate('Module documentations'),
+ 'doc/module/',
+ null,
+ array('title' => $this->translate('List all modules for which documentation is available'))
+ ) ?></li>
+ </ul>
+</div>
diff --git a/modules/doc/application/views/scripts/module/index.phtml b/modules/doc/application/views/scripts/module/index.phtml
new file mode 100644
index 0000000..f70d69a
--- /dev/null
+++ b/modules/doc/application/views/scripts/module/index.phtml
@@ -0,0 +1,19 @@
+<div class="controls">
+ <?= $this->tabs ?>
+</div>
+
+<div class="content">
+ <ul>
+ <?php foreach ($modules as $module): /** @var \Icinga\Application\Modules\Module $module */ ?>
+ <li><?= $this->qlink(
+ $module->getTitle(),
+ 'doc/module/toc',
+ array('moduleName' => $module->getName()),
+ array('title' => sprintf(
+ $this->translate('Show the documentation\'s table of contents for the %s'),
+ $module->getTitle()
+ ))
+ ) ?></li>
+ <?php endforeach ?>
+ </ul>
+</div>
diff --git a/modules/doc/application/views/scripts/pdf.phtml b/modules/doc/application/views/scripts/pdf.phtml
new file mode 100644
index 0000000..2666efb
--- /dev/null
+++ b/modules/doc/application/views/scripts/pdf.phtml
@@ -0,0 +1,5 @@
+<div class="content">
+ <h1><?= /** @var string $title */ $title ?></h1>
+ <?= /** @var \Icinga\Module\Doc\Renderer\DocTocRenderer $toc */ $toc ?>
+ <?= /** @var \Icinga\Module\Doc\Renderer\DocSectionRenderer $section */ $section ?>
+</div>
diff --git a/modules/doc/application/views/scripts/search/index.phtml b/modules/doc/application/views/scripts/search/index.phtml
new file mode 100644
index 0000000..c613f04
--- /dev/null
+++ b/modules/doc/application/views/scripts/search/index.phtml
@@ -0,0 +1,8 @@
+<div class="content">
+ <?php foreach (/** @var \Icinga\Module\Doc\Renderer\DocSearchRenderer[] $searches */ $searches as $title => $search): ?>
+ <h2><?= $this->escape($title) ?></h2>
+ <?= $search->isEmpty()
+ ? $this->translate('No documentation found matching the filter')
+ : $search ?>
+ <?php endforeach ?>
+</div>
diff --git a/modules/doc/application/views/scripts/style/font.phtml b/modules/doc/application/views/scripts/style/font.phtml
new file mode 100644
index 0000000..40a70ee
--- /dev/null
+++ b/modules/doc/application/views/scripts/style/font.phtml
@@ -0,0 +1,14 @@
+<div class="controls">
+<?= $this->tabs ?>
+<h1>Icinga Web 2 Icons</h1>
+</div>
+
+<div class="content">
+<?php foreach ($this->font->glyphs as $icon): ?>
+<div class="icon <?=
+ $this->font->css_prefix_text . $icon->css
+?>">
+<?= $this->escape($icon->css) ?> <span class="icon-code">(0x<?= dechex($icon->code) ?>)</span>
+</div>
+<?php endforeach ?>
+</div>
diff --git a/modules/doc/application/views/scripts/style/guide.phtml b/modules/doc/application/views/scripts/style/guide.phtml
new file mode 100644
index 0000000..f2f57d2
--- /dev/null
+++ b/modules/doc/application/views/scripts/style/guide.phtml
@@ -0,0 +1,112 @@
+<div class="controls">
+ <?= $this->tabs ?>
+</div>
+
+<div class="content styleguide">
+ <div class="section">
+ <h1>Icinga Web 2 Design Guidelines</h1>
+
+ <ul class="toc">
+ <li><a href="#headings">Headings</a></li>
+ <li><a href="#block-content">Block Content</a></li>
+ <li><a href="#tables">Tables</a></li>
+ <li><a href="#comment-list">Comment List</a></li>
+ <li><a href="#blockquote">Blockquote</a></li>
+ </ul>
+ </div>
+
+ <div class="section">
+ <h2 id="headings">Headings</h2>
+ <h1>Header h1</h1>
+ <h2>Header h2</h2>
+ <h3>Header h3</h3>
+ <h4>Header h4</h4>
+ <h5>Header h5</h5>
+ <h6>Header h6</h6>
+ </div>
+
+ <div class="section">
+ <h2 id="block-content">Block Content</h2>
+ <h3>Paragraph</h3>
+ <p>
+ This is a paragraph. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
+ invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo
+ dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
+ A <a href="#">link inside a paragraph</a>.
+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et
+ dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
+ Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
+ </p>
+ </div>
+
+ <div class="section">
+ <h2 id="tables">Tables</h2>
+ <table class="common-table">
+ <thead>
+ <tr>
+ <th>Table Head - th in thead</th>
+ <td>td in thead<td>
+ </tr>
+ </thead>
+ <tbody>
+ <tr>
+ <th>Tbody - th</th>
+ <td>Tbody - td</td>
+ </tr>
+ <tr>
+ <th>Tbody - th</th>
+ <td>Tbody - td</td>
+ </tr>
+ <tr>
+ <th>Tbody - th</th>
+ <td>Tbody - td</td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+
+ <div class="section">
+ <h2 id="comment-list"><?= $this->translate('Comment List') ?></h2>
+ <dl class="comment-list">
+ <dt>
+ John Doe
+ <span class="comment-time">
+ <?= $this->translate('commented') ?>
+ <span class="relative-time"><?= $this->translate('some time ago') ?></span>
+ </span>
+ <i class="remove-action icon-cancel"></i>
+ </dt>
+ <dd>
+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore
+ <br>
+ et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
+ </dd>
+ <dt>
+ Richard Roe
+ <span class="comment-time">
+ <?= $this->translate('commented') ?>
+ <span class="relative-time"><?= $this->translate('some time ago') ?></span>
+ </span>
+ <i class="remove-action icon-cancel"></i>
+ </dt>
+ <dd>
+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore
+ <br>
+ et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
+ </dd>
+ </dl>
+ </div>
+
+ <div class="section">
+ <h2 id="blockquote"><?= $this->translate('Blockquote') ?></h2>
+ <blockquote>
+ Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor
+ invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua.
+ At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,
+ no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet,
+ consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore
+ magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum.
+ Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.
+ </blockquote>
+ </div>
+</div>
diff --git a/modules/doc/application/views/scripts/toc.phtml b/modules/doc/application/views/scripts/toc.phtml
new file mode 100644
index 0000000..d08830b
--- /dev/null
+++ b/modules/doc/application/views/scripts/toc.phtml
@@ -0,0 +1,6 @@
+<div class="controls">
+ <?= /** @var \Icinga\Web\Widget\Tabs $tabs */ $tabs ?>
+</div>
+<div class="content">
+ <?= /** @var \Icinga\Module\Doc\Renderer\DocTocRenderer $toc */ $toc ?>
+</div>
diff --git a/modules/doc/configuration.php b/modules/doc/configuration.php
new file mode 100644
index 0000000..8b909ef
--- /dev/null
+++ b/modules/doc/configuration.php
@@ -0,0 +1,24 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+/** @var $this \Icinga\Application\Modules\Module */
+
+$section = $this->menuSection(N_('Documentation'), array(
+ 'title' => 'Documentation',
+ 'icon' => 'book',
+ 'url' => 'doc',
+ 'priority' => 700
+));
+
+$section->add('Icinga Web 2', array(
+ 'url' => 'doc/icingaweb/toc',
+));
+$section->add('Module documentations', array(
+ 'url' => 'doc/module',
+));
+$section->add(N_('Developer - Style'), array(
+ 'url' => 'doc/style/guide',
+ 'priority' => 790
+));
+
+$this->provideSearchUrl($this->translate('Doc'), 'doc/search', -10);
diff --git a/modules/doc/doc/01-About.md b/modules/doc/doc/01-About.md
new file mode 100644
index 0000000..02e2cbf
--- /dev/null
+++ b/modules/doc/doc/01-About.md
@@ -0,0 +1,6 @@
+# About the Doc Module <a id="doc-module-about"></a>
+
+Please read the following chapters for more insights on this module:
+
+* [Installation](02-Installation.md#doc-module-installation)
+* [Module Documentation](03-Module-Documentation.md#module-documentation)
diff --git a/modules/doc/doc/02-Installation.md b/modules/doc/doc/02-Installation.md
new file mode 100644
index 0000000..6d93d42
--- /dev/null
+++ b/modules/doc/doc/02-Installation.md
@@ -0,0 +1,15 @@
+# Doc Module Installation <a id="doc-module-installation"></a>
+
+This module is provided with the Icinga Web 2 package and does
+not need any extra installation step.
+
+## Enable the Module <a id="monitoring-module-enable"></a>
+
+Navigate to `Configuration` -> `Modules` -> `doc` and enable
+the module.
+
+You can also enable the module during the setup wizard, or on the CLI:
+
+```
+icingacli module enable doc
+```
diff --git a/modules/doc/doc/03-Module-Documentation.md b/modules/doc/doc/03-Module-Documentation.md
new file mode 100644
index 0000000..5ce4a9a
--- /dev/null
+++ b/modules/doc/doc/03-Module-Documentation.md
@@ -0,0 +1,87 @@
+# Writing Module Documentation <a id="module-documentation"></a>
+
+![Markdown](img/markdown.png)
+
+Icinga Web 2 is capable of viewing your module's documentation, if the documentation is written in
+[Markdown](http://en.wikipedia.org/wiki/Markdown). Please refer to
+[Markdown Syntax Documentation](http://daringfireball.net/projects/markdown/syntax) for Markdown's formatting syntax.
+
+## Where to Put Module Documentation? <a id="module-documentation-location"></a>
+
+By default, your module's Markdown documentation files must be placed in the `doc` directory beneath your module's root
+directory, e.g.:
+
+```
+example-module/doc
+```
+
+## Chapters <a id="module-documentation-chapters"></a>
+
+Each Markdown documentation file represents a chapter of your module's documentation. The first found heading inside
+each file is the chapter's title. The order of chapters is based on the case insensitive "Natural Order" of your files'
+names. <dfn>Natural Order</dfn> means that the file names are ordered in the way which seems natural to humans.
+It is best practice to prefix Markdown documentation file names with numbers to ensure that they appear in the correct
+order, e.g.:
+
+```
+01-About.md
+02-Installation.md
+03-Configuration.md
+```
+
+## Table Of Contents <a id="module-documentation-toc"></a>
+
+The table of contents for your module's documentation is auto-generated based on all found headings inside each
+Markdown documentation file.
+
+## Linking Between Headings <a id="module-documentation-linking"></a>
+
+For linking between headings, place an anchor **after the text** where you want to link to, e.g.:
+
+```
+# Heading <a id="heading"></a> Heading
+```
+
+Please note that anchors have to be unique across all your Markdown documentation files.
+
+Now you can reference the anchor either in the same or **in another** Markdown documentation file, e.g.:
+
+```
+This is a link to [Heading](#heading).
+```
+
+Other tools support linking between headings by giving the filename plus the anchor to link to, e.g.:
+
+```
+This is a link to [About/Heading](01-About.md#heading)
+```
+
+This syntax is also supported in Icinga Web 2.
+
+## Including Images <a id="module-documentation-images"></a>
+
+Images must placed in the `doc` directory beneath your module's root directory, e.g.:
+
+```
+/path/to/icingaweb2/modules/example-module/doc/img/example.png
+```
+
+Note that the `img` sub directory is not mandatory but good for organizing your directory structure.
+
+Module images can be accessed using the following URL:
+
+```
+{baseURL}/doc/module/{moduleName}/image/{image} e.g. icingaweb2/doc/module/example-module/image/img/example.png
+```
+
+Markdown's image syntax is very similar to Markdown's link syntax, but prefixed with an exclamation mark, e.g.:
+
+```
+![Alt text](http://path/to/img.png "Optional Title")
+```
+
+URLs to images inside your Markdown documentation files must be specified without the base URL, e.g.:
+
+```
+![Example](img/example.png)
+```
diff --git a/modules/doc/doc/img/markdown.png b/modules/doc/doc/img/markdown.png
new file mode 100644
index 0000000..93e729b
--- /dev/null
+++ b/modules/doc/doc/img/markdown.png
Binary files differ
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);
+ }
+}
diff --git a/modules/doc/module.info b/modules/doc/module.info
new file mode 100644
index 0000000..0de946d
--- /dev/null
+++ b/modules/doc/module.info
@@ -0,0 +1,4 @@
+Module: doc
+Version: 2.12.1
+Description: Documentation module
+ Extracts, shows and exports documentation for Icinga Web 2 and its modules.
diff --git a/modules/doc/public/css/module.less b/modules/doc/public/css/module.less
new file mode 100644
index 0000000..da322a2
--- /dev/null
+++ b/modules/doc/public/css/module.less
@@ -0,0 +1,120 @@
+/*! Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+// Mixins
+
+.gradient(@a: @gray-lighter; @b: @gray-lightest) {
+ background: @a;
+ background: -webkit-gradient(linear, left top, left bottom, from(@a), to(@b));
+ background: -webkit-linear-gradient(top, @a, @b);
+ background: -moz-linear-gradient(top, @a, @b);
+ background: -ms-linear-gradient(top, @a, @b);
+ background: -o-linear-gradient(top, @a, @b);
+ background: linear-gradient(to bottom, @a, @b);
+}
+
+// General styles
+
+code {
+ color: @icinga-blue;
+ font-family: @font-family-fixed;
+}
+
+pre > code {
+ color: inherit;
+}
+
+.chapter a {
+ border-bottom: 1px dotted @gray-light;
+ font-weight: @font-weight-bold;
+
+ &:hover {
+ border-bottom: 1px solid @text-color;
+ text-decoration: none;
+ }
+}
+
+.content {
+ font-size: 1.167em;
+}
+
+.search-highlight {
+ .rounded-corners();
+
+ background: @icinga-blue;
+ color: @text-color-on-icinga-blue;
+ padding: 0 0.3em 0 0.3em;
+}
+
+.toc {
+ counter-reset: li;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+
+ li {
+ counter-increment: li;
+ margin-top: 0.25em;
+
+ > .toc {
+ margin-left: 2em;
+ }
+
+ a {
+ &:before {
+ color: @icinga-blue;
+ content: counters(li,".") " ";
+ display: inline-block;
+ font-size: small;
+ font-weight: @font-weight-bold;
+ min-width: 1.5em;
+ padding: 0.25em;
+ text-align: center;
+ }
+
+ display: block;
+ }
+ }
+}
+
+// Table styles
+
+table {
+ margin-bottom: 1em;
+ width: 100%;
+}
+
+tbody > tr:nth-child(odd) {
+ .gradient()
+}
+
+tbody > tr:nth-child(even) {
+ background: @body-bg-color;
+}
+
+td, th {
+ padding: 0.5em;
+}
+
+td {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+th {
+ border-bottom: 2px solid @icinga-blue;
+ font-weight: @font-weight-bold;
+ text-align: left;
+ text-transform: uppercase;
+}
+
+.icon {
+ width: 33%;
+ font-size: 1.5em;
+ height: 2em;
+ float: left;
+}
+
+.icon-code {
+ font-size: 0.6em;
+}
diff --git a/modules/doc/public/js/module.js b/modules/doc/public/js/module.js
new file mode 100644
index 0000000..d5571ee
--- /dev/null
+++ b/modules/doc/public/js/module.js
@@ -0,0 +1,30 @@
+/*! Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */
+
+(function(Icinga) {
+
+ var Doc = function(module) {
+ this.module = module;
+ this.initialize();
+ this.module.icinga.logger.debug('Doc module loaded');
+ };
+
+ Doc.prototype = {
+
+ initialize: function()
+ {
+ this.module.on('rendered', this.rendered);
+ this.module.icinga.logger.debug('Doc module initialized');
+ },
+
+ rendered: function(event) {
+ var $container = $(event.currentTarget);
+ if ($('> .content.styleguide', $container).length) {
+ $container.removeClass('module-doc');
+ }
+ }
+ };
+
+ Icinga.availableModules.doc = Doc;
+
+}(Icinga));
+
diff --git a/modules/doc/run.php b/modules/doc/run.php
new file mode 100644
index 0000000..df9dd09
--- /dev/null
+++ b/modules/doc/run.php
@@ -0,0 +1,64 @@
+<?php
+/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */
+
+use Icinga\Application\Icinga;
+
+if (Icinga::app()->isCli()) {
+ return;
+}
+
+$docModuleChapter = new Zend_Controller_Router_Route(
+ 'doc/module/:moduleName/chapter/:chapter',
+ array(
+ 'controller' => 'module',
+ 'action' => 'chapter',
+ 'module' => 'doc'
+ )
+);
+
+$docIcingaWebChapter = new Zend_Controller_Router_Route(
+ 'doc/icingaweb/chapter/:chapter',
+ 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'
+ )
+);
+
+$docModuleImg = new Zend_Controller_Router_Route_Regex(
+ 'doc/module/([^/]+)/image/(.+)',
+ array(
+ 'controller' => 'module',
+ 'action' => 'image',
+ 'module' => 'doc'
+ ),
+ array(
+ 'moduleName' => 1,
+ 'image' => 2
+ ),
+ 'doc/module/%s/image/%s'
+);
+
+$this->addRoute('doc/module/chapter', $docModuleChapter);
+$this->addRoute('doc/icingaweb/chapter', $docIcingaWebChapter);
+$this->addRoute('doc/module/toc', $docModuleToc);
+$this->addRoute('doc/module/pdf', $docModulePdf);
+$this->addRoute('doc/module/img', $docModuleImg);