summaryrefslogtreecommitdiffstats
path: root/modules/doc/library/Doc/DocParser.php
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--modules/doc/library/Doc/DocParser.php235
1 files changed, 235 insertions, 0 deletions
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;
+ }
+}