Merge branch 'feature/display-documentation-4820'

fixes #6730
resolves #4820
resolves #6303
resolves #6484
resolves #6629
resolves #6633
resolves #6726
This commit is contained in:
Eric Lippmann 2014-08-19 13:41:21 +02:00
commit d29eb21b5f
35 changed files with 1571 additions and 288 deletions

View File

@ -0,0 +1,5 @@
[Documentation]
title = "Documentation"
icon = "img/icons/comment.png"
url = "doc"
priority = 80

View File

@ -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',
}

View File

@ -0,0 +1,5 @@
[Documentation]
title = "Documentation"
icon = "img/icons/comment.png"
url = "doc"
priority = 80

View File

@ -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;
}
}

View File

@ -0,0 +1,18 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Data;
/**
* Interface for objects that are identifiable by an ID of any type
*/
interface Identifiable
{
/**
* Get the ID associated with this Identifiable object
*
* @return mixed
*/
public function getId();
}

View File

@ -0,0 +1,79 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Data\Tree;
use SplDoublyLinkedList;
class Node extends SplDoublyLinkedList implements NodeInterface
{
/**
* The node's value
*
* @var mixed
*/
protected $value;
/**
* Create a new node
*
* @param mixed $value The node's value
*/
public function __construct($value = null)
{
$this->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;
}
}

View File

@ -0,0 +1,26 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Data\Tree;
use RecursiveIterator;
interface NodeInterface extends RecursiveIterator
{
/**
* 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);
/**
* Get the node's value
*
* @return mixed
*/
public function getValue();
}

View File

@ -0,0 +1,48 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
use \Zend_Controller_Action_Exception;
use Icinga\Application\Icinga;
use Icinga\Module\Doc\DocController;
class Doc_IcingawebController extends DocController
{
/**
* View the toc of Icinga Web 2's documentation
*/
public function tocAction()
{
$this->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');
}
}

View File

@ -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() {}
}

View File

@ -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)
);
}
}

View File

@ -0,0 +1,3 @@
<div class="chapter">
<?= $sectionRenderer->render($this, $this->getHelper('Url')); ?>
</div>

View File

@ -1,5 +1,6 @@
<h1>Icinga 2 Documentation</h1>
<?= $this->partial('module/view.phtml', 'doc', array(
'toc' => $toc,
'html' => $html
)); ?>
<div class="controls"></div>
<h1><?= $this->translate('Available documentations'); ?></h1>
<ul>
<li><a href="<?= $this->href('doc/icingaweb/toc'); ?>">Icinga Web 2</a></li>
<li><a href="<?= $this->href('doc/module/'); ?>"><?= $this->translate('Module documentations'); ?></a></li>
</ul>

View File

@ -1,14 +0,0 @@
<div class="controls">
<h1>Module documentations</h1>
</div>
<div class="content" data-base-target="_next">
<?= $this->partial(
'layout/menu.phtml',
'default',
array(
'items' => $toc->getChildren(),
'sub' => false,
'url' => ''
)
) ?>
</div>

View File

@ -1,6 +1,10 @@
<h1>Module documentations</h1>
<h1><?= $this->translate('Module documentations'); ?></h1>
<ul>
<?php foreach ($enabledModules as $module): ?>
<li><a href="<?= $this->href('doc/module/view', array('name' => $module)); ?>"><?= $module ?></a></li>
<?php endforeach ?>
<?php foreach ($modules as $module): ?>
<li>
<a href="<?= $this->getHelper('Url')->url(array('moduleName' => $module), 'doc/module/toc', false, false); ?>">
<?= $module ?>
</a>
</li>
<?php endforeach ?>
</ul>

View File

@ -1,7 +0,0 @@
<?php if ($html === null): ?>
<p>No documentation available.</p>
<?php else: ?>
<div class="content">
<?= $html ?>
</div>
<?php endif ?>

View File

@ -0,0 +1,7 @@
<h1><?= $docName ?> <?= $this->translate('Documentation'); ?></h1>
<div class="toc">
<?= $tocRenderer->render($this, $this->getHelper('Url')); ?>
</div>
<div class="chapter">
<?= $sectionRenderer->render($this, $this->getHelper('Url')); ?>
</div>

View File

@ -0,0 +1,6 @@
<div class="controls">
<h1><?= $title ?></h1>
</div>
<div class="content toc">
<?= $tocRenderer->render($this, $this->getHelper('Url')); ?>
</div>

View File

@ -1,23 +0,0 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc;
use Icinga\Web\Controller\ModuleActionController;
class Controller extends ModuleActionController
{
/**
* Set HTML and toc
*
* @param string $module
*/
protected function populateView($module = null)
{
$parser = new DocParser($module);
list($html, $toc) = $parser->getDocumentation();
$this->view->html = $html;
$this->view->toc = $toc;
}
}

View File

@ -0,0 +1,76 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc;
use Icinga\Web\Controller\ModuleActionController;
class DocController extends ModuleActionController
{
/**
* Render a chapter
*
* @param string $path Path to the documentation
* @param string $chapterId ID of the chapter
* @param string $tocUrl
* @param string $url
* @param array $urlParams
*/
protected function renderChapter($path, $chapterId, $tocUrl, $url, array $urlParams = array())
{
$parser = new DocParser($path);
$this->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');
}
}

View File

@ -1,11 +0,0 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc;
use \Exception;
class DocException extends Exception
{
}

View File

@ -0,0 +1,62 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc;
use ArrayIterator;
use Countable;
use IteratorAggregate;
use RecursiveIteratorIterator;
use RecursiveDirectoryIterator;
/**
* Iterator over non-empty Markdown files ordered by the case insensitive "natural order" of file names
*/
class DocIterator implements Countable, IteratorAggregate
{
/**
* Ordered files
*
* @var array
*/
protected $fileInfo;
/**
* Create a new DocIterator
*
* @param string $path Path to the documentation
*/
public function __construct($path)
{
$it = new RecursiveIteratorIterator(
new NonEmptyFileIterator(
new MarkdownFileIterator(
new RecursiveDirectoryIterator($path)
)
)
);
// Unfortunately we have no chance to sort the iterator
$fileInfo = iterator_to_array($it);
natcasesort($fileInfo);
$this->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);
}
}

View File

@ -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('.', '&#46;', 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 = '<a name="' . $id . '"></a>' . 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(
'#<pre><code class="language-php">(.*?)\</code></pre>#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('#(?:<(?P<tag>a|span) id="(?P<id>.+)"></(?P=tag)>)#u', $header, $match)
if ($header[0] === '<'
&& preg_match('#(?:<(?P<tag>a|span) (?:id|name)="(?P<id>.+)"></(?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;
}
}

View File

@ -0,0 +1,80 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc;
use LogicException;
use Icinga\Data\Identifiable;
use Icinga\Data\Tree\Node;
/**
* Documentation tree
*/
class DocTree extends Node
{
/**
* All nodes of the tree
*
* @var array
*/
protected $nodes = array();
/**
* Append a root node to the tree
*
* @param Identifiable $root
*/
public function addRoot(Identifiable $root)
{
$rootId = $root->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];
}
}

View File

@ -0,0 +1,10 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc\Exception;
/**
* Exception thrown if a chapter was not found
*/
class ChapterNotFoundException extends DocException {}

View File

@ -0,0 +1,10 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc\Exception;
/**
* Exception thrown if a documentation directory is empty
*/
class DocEmptyException extends DocException {}

View File

@ -0,0 +1,12 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc\Exception;
use RuntimeException;
/**
* Exception thrown if an error in the documentation module's library occurs
*/
class DocException extends RuntimeException {}

View File

@ -4,15 +4,15 @@
namespace Icinga\Module\Doc;
use \RecursiveFilterIterator;
use RecursiveFilterIterator;
/**
* Iterator over Markdown files recursively
* Recursive iterator over Markdown files
*/
class MarkdownFileIterator extends RecursiveFilterIterator
{
/**
* Accept files with .md suffix
* Accept files with '.md' suffix
*
* @return bool Whether the current element of the iterator is acceptable
* through this filter
@ -20,7 +20,8 @@ class MarkdownFileIterator extends RecursiveFilterIterator
public function accept()
{
$current = $this->getInnerIterator()->current();
if (!$current->isFile()) {
/* @var $current \SplFileInfo */
if (! $current->isFile()) {
return false;
}
$filename = $current->getFilename();

View File

@ -0,0 +1,31 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}
namespace Icinga\Module\Doc;
use RecursiveFilterIterator;
/**
* Recursive iterator over non-empty files
*/
class NonEmptyFileIterator extends RecursiveFilterIterator
{
/**
* Accept non-empty files
*
* @return bool Whether the current element of the iterator is acceptable
* through this filter
*/
public function accept()
{
$current = $this->getInnerIterator()->current();
/* @var $current \SplFileInfo */
if (! $current->isFile()
|| $current->getSize() === 0
) {
return false;
}
return true;
}
}

View File

@ -0,0 +1,75 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc;
use RecursiveIteratorIterator;
use Zend_View_Helper_Url;
use Icinga\Web\View;
/**
* Base class for toc and section renderer
*/
abstract class Renderer extends RecursiveIteratorIterator
{
/**
* 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
*
* Meant to be overwritten by concrete classes.
*
* @param View $view
* @param Zend_View_Helper_Url $zendUrlHelper
*
* @return string
*/
abstract public function render(View $view, Zend_View_Helper_Url $zendUrlHelper);
}

View File

@ -0,0 +1,143 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}}
namespace Icinga\Module\Doc;
use Icinga\Data\Identifiable;
/**
* A section of a documentation
*/
class Section implements Identifiable
{
/**
* The ID of the section
*
* @var string
*/
protected $id;
/**
* The title of the section
*
* @var string
*/
protected $title;
/**
* The header level
*
* @var int
*/
protected $level;
/**
* Whether to instruct search engines to not index the link to the section
*
* @var bool
*/
protected $noFollow;
/**
* The ID of the chapter the section is part of
*
* @var string
*/
protected $chapterId;
/**
* The content of the section
*
* @var array
*/
protected $content = array();
/**
* Create a new section
*
* @param string $id The ID of the section
* @param string $title The title of the section
* @param int $level The header level
* @param bool $noFollow Whether to instruct search engines to not index the link to the section
* @param string $chapterId The ID of the chapter the section is part of
*/
public function __construct($id, $title, $level, $noFollow, $chapterId)
{
$this->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;
}
}

View File

@ -0,0 +1,68 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}
namespace Icinga\Module\Doc;
use Countable;
use RecursiveFilterIterator;
use Icinga\Data\Tree\NodeInterface;
/**
* Recursive iterator over sections that are part of a particular chapter
*/
class SectionFilterIterator extends RecursiveFilterIterator implements Countable
{
/**
* The chapter ID to filter for
*
* @var string
*/
protected $chapterId;
/**
* Create a new SectionFilterIterator
*
* @param NodeInterface $node Node
* @param string $chapterId The chapter ID to filter for
*/
public function __construct(NodeInterface $node, $chapterId)
{
parent::__construct($node);
$this->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);
}
}

View File

@ -0,0 +1,292 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}
namespace Icinga\Module\Doc;
require_once 'IcingaVendor/Parsedown/Parsedown.php';
use DOMDocument;
use DOMXPath;
use RecursiveIteratorIterator;
use Parsedown;
use Zend_View_Helper_Url;
use Icinga\Module\Doc\Exception\ChapterNotFoundException;
use Icinga\Web\Url;
use Icinga\Web\View;
/**
* preg_replace_callback helper to replace links
*/
class Callback
{
protected $docTree;
protected $view;
protected $zendUrlHelper;
protected $url;
protected $urlParams;
public function __construct(
DocTree $docTree,
View $view,
Zend_View_Helper_Url $zendUrlHelper,
$url,
array $urlParams)
{
$this->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(
'<a %s%shref="%s"',
strlen($match['attribs']) ? trim($match['attribs']) . ' ' : '',
$section->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 '<pre>' . highlight_string(htmlspecialchars_decode($match[1]), true) . '</pre>';
}
/**
* 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(
'<a name="%1$s"></a><h%2$d>%3$s</h%2$d>',
Renderer::encodeAnchor($section->getId()),
$section->getLevel(),
$view->escape($section->getTitle())
);
$html = preg_replace_callback(
'#<pre><code class="language-php">(.*?)</code></pre>#s',
array($this, 'highlightPhp'),
$this->parsedown->text(implode('', $section->getContent()))
);
$html = preg_replace_callback(
'/<img[^>]+>/',
array($this, 'replaceImg'),
$html
);
$content[] = preg_replace_callback(
'/<a\s+(?P<attribs>[^>]*?\s+)?href="#(?P<fragment>[^"]+)"/',
array($callback, 'render'),
$html
);
}
if ($renderNavigation) {
foreach ($this->docTree as $chapter) {
if ($chapter->getValue()->getId() === $section->getChapterId()) {
$navigation = array('<ul class="navigation">');
$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(
'<li class="prev"><a %shref="%s">%s</a></li>',
$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(
'<li><a href="%s">%s</a></li>',
$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(
'<li class="next"><a %shref="%s">%s</a></li>',
$next->isNoFollow() ? 'rel="nofollow" ' : '',
$url->getAbsoluteUrl(),
$view->escape($next->getTitle())
);
}
$navigation[] = '</ul>';
$content = array_merge($navigation, $content, $navigation);
break;
}
}
}
return implode("\n", $content);
}
}

View File

@ -0,0 +1,109 @@
<?php
// {{{ICINGA_LICENSE_HEADER}}}
// {{{ICINGA_LICENSE_HEADER}}
namespace Icinga\Module\Doc;
use RecursiveIteratorIterator;
use Zend_View_Helper_Url;
use Icinga\Web\View;
/**
* TOC renderer
*/
class TocRenderer extends Renderer
{
/**
* The URL to replace links with
*
* @var string
*/
protected $url;
/**
* Additional URL parameters
*
* @var array
*/
protected $urlParams;
/**
* Content
*
* @var array
*/
protected $content = array();
/**
* Create a new toc renderer
*
* @param DocTree $docTree The documentation tree
* @param string $url The URL to replace links with
* @param array $urlParams Additional URL parameters
*/
public function __construct(DocTree $docTree, $url, array $urlParams)
{
parent::__construct($docTree, RecursiveIteratorIterator::SELF_FIRST);
$this->url = $url;
$this->urlParams = array_map(array($this, 'encodeUrlParam'), $urlParams);
}
public function beginIteration()
{
$this->content[] = '<nav><ul>';
}
public function endIteration()
{
$this->content[] = '</ul></nav>';
}
public function beginChildren()
{
$this->content[] = '<ul>';
}
public function endChildren()
{
$this->content[] = '</ul></li>';
}
/**
* 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(
'<li><a %shref="%s">%s</a>',
$section->isNoFollow() ? 'rel="nofollow" ' : '',
$url->getAbsoluteUrl(),
$view->escape($section->getTitle())
);
if (! $this->getInnerIterator()->current()->hasChildren()) {
$this->content[] = '</li>';
}
}
return implode("\n", $this->content);
}
}

View File

@ -0,0 +1,62 @@
// W3C Recommendation <http://www.w3.org/TR/CSS21/sample.html> (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;
}
}
}

50
modules/doc/run.php Normal file
View File

@ -0,0 +1,50 @@
<?php
use \Zend_Controller_Router_Route;
use Icinga\Application\Icinga;
if (Icinga::app()->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);