Add Table Of Contents feature

This commit is contained in:
Stéphane Goetz
2016-04-12 08:38:52 +02:00
parent 9a615ea55a
commit b037a43e2d
17 changed files with 496 additions and 7 deletions

View File

@ -3,6 +3,8 @@
use League\CommonMark\DocParser;
use League\CommonMark\Environment;
use League\CommonMark\HtmlRenderer;
use Todaymade\Daux\ContentTypes\Markdown\TOC\Parser;
use Todaymade\Daux\ContentTypes\Markdown\TOC\TOCProcessor;
use Webuni\CommonMark\TableExtension\TableExtension;
class CommonMarkConverter extends \League\CommonMark\CommonMarkConverter
@ -18,6 +20,10 @@ class CommonMarkConverter extends \League\CommonMark\CommonMarkConverter
$environment->mergeConfig($config);
$environment->addExtension(new TableExtension());
// Table of Contents
$environment->addBlockParser(new Parser());
$environment->addDocumentProcessor(new TOCProcessor($config['daux']));
$this->extendEnvironment($environment);
if (array_key_exists('processor_instance', $config['daux'])) {

View File

@ -0,0 +1,50 @@
<?php namespace Todaymade\Daux\ContentTypes\Markdown\TOC;
use League\CommonMark\Block\Element\AbstractBlock;
use League\CommonMark\Cursor;
class Element extends AbstractBlock
{
/**
* Returns true if this block can contain the given block as a child node
*
* @param AbstractBlock $block
*
* @return bool
*/
public function canContain(AbstractBlock $block)
{
return false;
}
/**
* Returns true if block type can accept lines of text
*
* @return bool
*/
public function acceptsLines()
{
return false;
}
/**
* Whether this is a code block
*
* @return bool
*/
public function isCode()
{
return false;
}
/**
* @param Cursor $cursor
*
* @return bool
*/
public function matchesNextLine(Cursor $cursor)
{
return false;
}
}

View File

@ -0,0 +1,83 @@
<?php namespace Todaymade\Daux\ContentTypes\Markdown\TOC;
use League\CommonMark\Block\Element\Heading;
class Entry
{
protected $content;
protected $level;
protected $parent = null;
protected $children = [];
public function __construct(Heading $content)
{
$this->content = $content;
$this->level = $content->getLevel();
}
/**
* @return string
*/
public function getId()
{
return $this->content->data['attributes']['id'];
}
/**
* @return int
*/
public function getLevel()
{
return $this->level;
}
/**
* @return Entry
*/
public function getParent()
{
return $this->parent;
}
/**
* @return Heading
*/
public function getContent()
{
return $this->content;
}
/**
* @return Entry[]
*/
public function getChildren()
{
return $this->children;
}
/**
* @param Entry $parent
* @param bool $addChild
*/
public function setParent(Entry $parent, $addChild = true)
{
$this->parent = $parent;
if ($addChild) {
$parent->addChild($this);
}
}
/**
* @param Entry $child
*/
public function addChild(Entry $child)
{
$child->setParent($this, false);
$this->children[] = $child;
}
public function toString()
{
return $this->getLevel() . " - " . $this->getId();
}
}

View File

@ -0,0 +1,44 @@
<?php
/**
* Created by IntelliJ IDEA.
* User: onigoetz
* Date: 09/04/16
* Time: 23:03
*/
namespace Todaymade\Daux\ContentTypes\Markdown\TOC;
use League\CommonMark\Block\Parser\AbstractBlockParser;
use League\CommonMark\ContextInterface;
use League\CommonMark\Cursor;
class Parser extends AbstractBlockParser
{
/**
* @param ContextInterface $context
* @param Cursor $cursor
*
* @return bool
*/
public function parse(ContextInterface $context, Cursor $cursor)
{
if ($cursor->isIndented()) {
return false;
}
$previousState = $cursor->saveState();
$cursor->advanceToFirstNonSpace();
$fence = $cursor->match('/^\[TOC\]/');
if (is_null($fence)) {
$cursor->restoreState($previousState);
return false;
}
$context->addBlock(new Element());
return true;
}
}

View File

@ -0,0 +1,18 @@
<?php namespace Todaymade\Daux\ContentTypes\Markdown\TOC;
class RootEntry extends Entry
{
public function __construct()
{
$this->content = null;
$this->level = 0;
}
/**
* @return Entry
*/
public function getParent()
{
throw new \RuntimeException("No Parent Exception");
}
}

View File

@ -0,0 +1,203 @@
<?php namespace Todaymade\Daux\ContentTypes\Markdown\TOC;
use DeepCopy\DeepCopy;
use League\CommonMark\Block\Element\Document;
use League\CommonMark\Block\Element\Heading;
use League\CommonMark\Block\Element\ListBlock;
use League\CommonMark\Block\Element\ListData;
use League\CommonMark\Block\Element\ListItem;
use League\CommonMark\Block\Element\Paragraph;
use League\CommonMark\DocumentProcessorInterface;
use League\CommonMark\Inline\Element\Link;
use League\CommonMark\Inline\Element\Text;
use League\CommonMark\Node\Node;
use ReflectionMethod;
use Todaymade\Daux\Config;
use Todaymade\Daux\DauxHelper;
class TOCProcessor implements DocumentProcessorInterface
{
protected $config;
public function __construct(Config $config)
{
$this->config = $config;
}
public function hasAutoTOC()
{
return array_key_exists('auto_toc', $this->config) && $this->config['auto_toc'];
}
/**
* @param Document $document
*
* @return void
*/
public function processDocument(Document $document)
{
/** @var Element[] $tocs */
$tocs = [];
$headings = [];
$walker = $document->walker();
while ($event = $walker->next()) {
$node = $event->getNode();
if ($node instanceof Element && !$event->isEntering()) {
$tocs[] = $node;
continue;
}
if (!($node instanceof Heading) || !$event->isEntering()) {
continue;
}
$id = $this->addId($node);
$headings[] = new Entry($node, $id);
}
if (count($headings) && (count($tocs) || $this->hasAutoTOC())) {
$generated = $this->generate($headings);
if (count($tocs)) {
foreach ($tocs as $toc) {
$toc->replaceWith($this->render($generated->getChildren()));
}
} else {
$document->prependChild($this->render($generated->getChildren()));
}
}
}
protected function addId(Heading $node)
{
// If the node has an ID, no need to generate it
$attributes = $node->getData('attributes', []);
if (array_key_exists('id', $attributes) && !empty($attributes['id'])) {
// TODO :: check for uniqueness
return $attributes['id'];
}
// Well, seems we have to generate an ID
$walker = $node->walker();
$inside = [];
while ($event = $walker->next()) {
$insideNode = $event->getNode();
if ($insideNode instanceof Heading) {
continue;
}
$inside[] = $insideNode;
}
$text = '';
foreach ($inside as $other) {
if ($other instanceof Text) {
$text .= ' ' . $other->getContent();
}
}
$text = 'page_' . DauxHelper::slug(trim($text));
// TODO :: check for uniqueness
$node->data['attributes']['id'] = $text;
}
/**
* @param Entry[] $headings
* @return RootEntry
*/
public function generate($headings)
{
/** @var Entry $previous */
$root = $previous = new RootEntry();
foreach ($headings as $heading) {
if ($heading->getLevel() < $previous->getLevel()) {
$parent = $previous;
do {
$parent = $parent->getParent();
} while ($heading->getLevel() <= $parent->getLevel() || $parent->getLevel() != 0);
$parent->addChild($heading);
$previous = $heading;
continue;
}
if ($heading->getLevel() > $previous->getLevel()) {
$previous->addChild($heading);
$previous = $heading;
continue;
}
//if ($heading->getLevel() == $previous->getLevel()) {
$previous->getParent()->addChild($heading);
$previous = $heading;
continue;
//}
}
return $root;
}
/**
* @param Entry[] $entries
* @return ListBlock
*/
protected function render(array $entries)
{
$data = new ListData();
$data->type = ListBlock::TYPE_UNORDERED;
$list = new ListBlock($data);
$list->data['attributes']['class'] = 'TableOfContents';
foreach ($entries as $entry) {
$item = new ListItem($data);
$a = new Link('#' . $entry->getId());
foreach ($this->cloneChildren($entry->getContent()) as $node) {
$a->appendChild($node);
}
$p = new Paragraph();
$p->appendChild($a);
$item->appendChild($p);
if (!empty($entry->getChildren())) {
$item->appendChild($this->render($entry->getChildren()));
}
$list->appendChild($item);
}
return $list;
}
/**
* @param Heading $node
* @return Node[]
*/
protected function cloneChildren(Heading $node)
{
$deepCopy = new DeepCopy();
$firstClone = clone $node;
// We have no choice but to hack into the system to reset the parent, to avoid cloning the complete tree
$method = new ReflectionMethod(get_class($firstClone), 'setParent');
$method->setAccessible(true);
$method->invoke($firstClone, null);
return $deepCopy->copy($firstClone)->children();
}
}