From 2b7add3f4fe0e5f7429880a752238136209bed59 Mon Sep 17 00:00:00 2001
From: Starbeamrainbowlabs <sbrl@starbeamrainbowlabs.com>
Date: Sat, 23 May 2020 22:28:33 +0100
Subject: [PATCH] parser: add table fo contents support

---
 modules/parser-parsedown.php | 123 +++++++++++++++++++++++++++++++++++
 peppermint.guiconfig.json    |   1 +
 2 files changed, 124 insertions(+)

diff --git a/modules/parser-parsedown.php b/modules/parser-parsedown.php
index 01e428a..1a53ed7 100644
--- a/modules/parser-parsedown.php
+++ b/modules/parser-parsedown.php
@@ -478,6 +478,14 @@ function parsedown_pagename_resolve($pagename) {
  */
 class PeppermintParsedown extends ParsedownExtra
 {
+	/**
+	 * A long random and extremely unlikely string to identify where we want
+	 * to put a table of contents.
+	 * Hopefully nobody is unlucky enough to include this in a page...!
+	 * @var string
+	 */
+	private const TOC_ID = "█yyZiy9c9oHVExhVummYZId_dO9-fvaGFvgQirEapxOtaL-s7WnK34lF9ObBoQ0EH2kvtd6VKcAL2█";
+	
 	/**
 	 * The base directory with which internal links will be resolved.
 	 * @var string
@@ -497,6 +505,8 @@ class PeppermintParsedown extends ParsedownExtra
 	{
         parent::__construct();
 		
+		array_unshift($this->BlockTypes["["], "TableOfContents");
+		
 		// Prioritise our internal link parsing over the regular link parsing
 		$this->addInlineType("[", "InternalLink", true);
 		// Prioritise the checkbox handling - this is fine 'cause it doesn't step on InternalLink's toes
@@ -534,6 +544,19 @@ class PeppermintParsedown extends ParsedownExtra
 			array_unshift($this->InlineTypes[$char], $function_id);
 	}
 	
+	/*
+	 * Override the text method here to insert the table of contents after
+	 * rendering has been completed
+	 */
+	public function text($text) {
+		$result = parent::text($text);
+		$toc_html = $this->generateTableOfContents();
+		$result = str_replace(self::TOC_ID, $toc_html, $result);
+		
+		return $result;
+	}
+	
+	
 	/*
 	 * ████████ ███████ ███    ███ ██████  ██       █████  ████████ ██ ███    ██  ██████
 	 *    ██    ██      ████  ████ ██   ██ ██      ██   ██    ██    ██ ████   ██ ██
@@ -1251,6 +1274,84 @@ class PeppermintParsedown extends ParsedownExtra
 	 */
 	
 	private $headingIdsUsed = [];
+	private $tableOfContents = [];
+	
+	/**
+	 * Inserts an item into the table of contents.
+	 * @param int    $level The level to insert it at (valid values: 1 - 6)
+	 * @param string $id    The id of the item.
+	 * @param string $text  The text to display.
+	 */
+	protected function addTableOfContentsEntry(int $level, string $id, string $text) : void {
+		$new_obj = (object) [
+			"level" => $level,
+			"id" => $id,
+			"text" => $text,
+			"children" => []
+		];
+		
+		if(count($this->tableOfContents) == 0) {
+			$this->tableOfContents[] = $new_obj;
+			return;
+		}
+		
+		$lastEntry = end($this->tableOfContents);
+		if($level > $lastEntry->level) {
+			$this->insertTableOfContentsObject($new_obj, $lastEntry);
+			return;
+		}
+		$this->tableOfContents[] = $new_obj;
+		return;
+	}
+	private function insertTableOfContentsObject(object $obj, object $target) {
+		if($obj->level - 1 > $target->level && !empty($target->children)) {
+			$this->insertTableOfContentsObject($obj, end($target->children));
+		}
+		$target->children[] = $obj;
+	}
+	
+	protected function generateTableOfContents() : string {
+		global $settings;
+		$elements = [ $this->generateTableOfContentsElement($this->tableOfContents) ];
+		if($settings->parser_toc_heading_level > 1)
+			array_unshift(
+				$elements,
+				[ "name" => "h$settings->parser_toc_heading_level", "text" => "Table of Contents" ]
+			);
+		
+		return trim($this->elements($elements), "\n");
+	}
+	private function generateTableOfContentsElement($toc) : array {
+		$elements = [];
+		foreach($toc as $entry) {
+			$next = [
+				"name" => "li",
+				"attributes" => [
+					"data-level" => $entry->level
+				],
+				"elements" => [ [
+					"name" => "a",
+					"attributes" => [
+						"href" => "#$entry->id"
+					],
+					"handler" => [
+						"function" => "lineElements",
+						"argument" => $entry->text,
+						"destination" => "elements"
+					]
+				] ]
+			];
+			if(isset($entry->children))
+				$next["elements"][] = $this->generateTableOfContentsElement($entry->children);
+			
+			$elements[] = $next;
+		}
+		
+		return [
+			"name" => "ul",
+			"elements" => $elements
+		];
+	}
 	
 	protected function blockHeader($line) {
 		// This function overrides the header function defined in ParsedownExtra
@@ -1273,6 +1374,28 @@ class PeppermintParsedown extends ParsedownExtra
 			$this->headingIdsUsed[] = $result["element"]["attributes"]["id"];
 		}
 		
+		$this->addTableOfContentsEntry(
+			intval(strtr($result["element"]["name"], [ "h" => "" ])),
+			$result["element"]["attributes"]["id"],
+			$result["element"]["handler"]["argument"]
+		);
+		
+		return $result;
+	}
+	
+	/*
+	 * Inserts a special string to identify where we need to put the table of contents later
+	 */
+	protected function blockTableOfContents($fragment) {
+		// Indent? Don't even want to know
+		if($fragment["indent"] > 0) return;
+		// If it doesn't match, then we're not interested
+		if(preg_match('/\[_*(?:TOC|toc)_*\]/u', $fragment["text"], $matches) !== 1)
+			return;
+		
+		$result = [
+			"element" => [ "text" => self::TOC_ID ]
+		];
 		return $result;
 	}
 	
diff --git a/peppermint.guiconfig.json b/peppermint.guiconfig.json
index edad153..be63d0b 100644
--- a/peppermint.guiconfig.json
+++ b/peppermint.guiconfig.json
@@ -71,6 +71,7 @@
 	} },
 	"parser_ext_time_limit": { "type": "number", "description": "The number of seconds external renderers are allowed to run for. Has no effect if external renderers are turned off. Also currently has no effect on Windows.", "default": 5 },
 	"parser_ext_allow_anon": { "type": "checkbox", "description": "<p>Whether to allow anonymous users to render new diagrams with the external renderer. When disabled, anonymous users will still be allowed to recall pre-rendered items from the cache, but will be unable to generate brand-new diagrams.</p><p>Note that if you allow anonymous edits this setting won't fully protect you: anonymous users could edit a page and insert a malicious diagram, and then laer a logged in user could unwittingly invoke the external renderer on the anonymous user's behalf.", "default": false },
+	"parser_toc_heading_level": { "type": "number", "description": "The level of heading to create when generating a table of contents. Corresponds directly with the HTML h1-h6 tags. A value of 0 disables the heading.", "default": 2 },
 	"interwiki_index_location": { "type": "text", "description": "The location to find the interwiki wiki definition file, which contains a list of wikis along with their names, prefixes, and root urls. May be a URL, or simply a file path - as it's passed to file_get_contents(). If left blank, interwiki link parsing is disabled.", "default": null },
 	"clean_raw_html": { "type": "checkbox", "description": "Whether page sources should be cleaned of HTML before rendering. It is STRONGLY recommended that you keep this option turned on.", "default": true },
 	"all_untrusted": { "type": "checkbox", "description": "Whether to treat both page sources and comment text as untrusted input. Untrusted input has additional restrictions to protect against XSS attacks etc. Turn on if your wiki allows anonymous edits.", "default": false},
