From e550c66ca46180a91f2443608fd8203d265dd1e2 Mon Sep 17 00:00:00 2001 From: William Jacoby Date: Mon, 12 Oct 2026 13:56:09 -0500 Subject: [PATCH 1/3] Add Wikipedia-style headings, section links and a table of contents == Heading == to ====== Heading ====== become h2 to h6 with anchors, a table of contents shows after the intro for four or more headings (__TOC__ places it, __FORCETOC__ forces it, __NOTOC__ hides it), [[Article#Section]] and [[#Section]] link to a heading, and a first line #REDIRECT [[Article]] sets the redirect. The text parser reads this only while a wiki article is saved or previewed, so forum posts are unchanged. A migration rebuilds the text formatter and reparses saved articles. Co-Authored-By: Claude Sonnet 5.5 --- event/wikilink_listener.php | 76 ++++++++- language/de/link.php | 1 + language/en/link.php | 1 + migrations/add_wiki_markup.php | 72 +++++++++ styles/prosilver/theme/style.css | 43 +++++ tests/unit/markup_test.php | 221 ++++++++++++++++++++++++++ textreparser/plugins/article_text.php | 20 +++ wiki/edit.php | 11 ++ wiki/markup.php | 198 +++++++++++++++++++++++ 9 files changed, 636 insertions(+), 7 deletions(-) create mode 100644 migrations/add_wiki_markup.php create mode 100644 tests/unit/markup_test.php create mode 100644 wiki/markup.php diff --git a/event/wikilink_listener.php b/event/wikilink_listener.php index 125b4d0..f543e7a 100644 --- a/event/wikilink_listener.php +++ b/event/wikilink_listener.php @@ -24,7 +24,9 @@ class wikilink_listener implements EventSubscriberInterface { /** Characters an article name in [[ ]] may not contain. */ - const NAME_PATTERN = '[^\[\]|<>"#?%\/\\\\\n]{1,255}'; + const NAME_CLASS = '[^\[\]|<>"#?%\/\\\\\n]'; + + const NAME_PATTERN = self::NAME_CLASS . '{1,255}'; /** @var \phpbb\controller\helper */ protected $helper; @@ -58,6 +60,7 @@ public static function getSubscribedEvents() { return array( 'core.text_formatter_s9e_configure_after' => 'configure_wikilink', + 'core.text_formatter_s9e_parse_before' => 'toggle_wiki_markup', 'core.text_formatter_s9e_render_after' => 'link_wikilinks', ); } @@ -88,15 +91,42 @@ public function configure_wikilink($event) 'WIKIFILE' ); + // == Heading == to ====== Heading ======, one per line. The parser reads these only + // while a wiki article is saved (see toggle_wiki_markup()); the anchors and the + // table of contents are added when the article is shown (see markup::finish()). + $heading = $configurator->tags->add('WIKIHEADING'); + $heading->attributes->add('marks'); + $heading->attributes->add('title'); + $heading->template = '' + . '

' + . '

' + . '

' + . '
' + . '
' + . '
'; + + $configurator->Preg->match( + '/^(?={2,6})[ \t]*(?[^=\n][^\n]*?)[ \t]*\k<marks>[ \t]*$/m', + 'WIKIHEADING' + ); + + // __TOC__, __NOTOC__ and __FORCETOC__ steer the table of contents + $magic = $configurator->tags->add('WIKIMAGIC'); + $magic->attributes->add('word'); + $magic->template = '<span class="wikimagic" data-wiki-magic="{@word}"></span>'; + + $configurator->Preg->match('/__(?<word>FORCETOC|NOTOC|TOC)__/', 'WIKIMAGIC'); + $tag = $configurator->tags->add('WIKILINK'); - $tag->attributes->add('name'); + $tag->attributes->add('name')->required = false; + $tag->attributes->add('section')->required = false; $tag->attributes->add('text')->required = false; - $tag->template = '<a class="wikilink" data-wiki-article="{@name}">' + $tag->template = '<a class="wikilink" data-wiki-article="{@name}" data-wiki-section="{@section}">' . '<xsl:choose><xsl:when test="@text"><xsl:value-of select="@text"/></xsl:when>' . '<xsl:otherwise><xsl:value-of select="@name"/></xsl:otherwise></xsl:choose></a>'; $configurator->Preg->match( - '/\[\[\s*(?!File:)(?<name>' . self::NAME_PATTERN . '?)\s*(?:\|\s*(?<text>[^\[\]\n]{1,255}?)\s*)?\]\]/', + '/\[\[\s*(?!File:)(?=[^\]|])(?<name>' . self::NAME_CLASS . '{0,255}?)\s*(?:#\s*(?<section>[^\[\]|<>"\n]{1,255}?)\s*)?(?:\|\s*(?<text>[^\[\]\n]{1,255}?)\s*)?\]\]/', 'WIKILINK' ); } @@ -108,10 +138,33 @@ public function configure_wikilink($event) * @param \phpbb\event\data $event The event object * @return void */ + public function toggle_wiki_markup($event) + { + $parser = $event['parser']->get_parser(); + + foreach (array('WIKIHEADING', 'WIKIMAGIC') as $tag) + { + if (\phpbbmodders\wiki\wiki\markup::is_active()) + { + $parser->enableTag($tag); + } + else + { + $parser->disableTag($tag); + } + } + } + public function link_wikilinks($event) { $html = $event['html']; + if (strpos($html, 'class="wikiheading"') !== false || strpos($html, 'class="wikimagic"') !== false) + { + $html = \phpbbmodders\wiki\wiki\markup::finish($html, $this->language->lang('WIKI_TOC')); + $event['html'] = $html; + } + if (strpos($html, 'class="wikifile"') !== false) { $html = $this->embed_files($html); @@ -123,7 +176,7 @@ public function link_wikilinks($event) return; } - $pattern = '/<a class="wikilink" data-wiki-article="([^"]*)">/'; + $pattern = '/<a class="wikilink" data-wiki-article="([^"]*)" data-wiki-section="([^"]*)">/'; if (!preg_match_all($pattern, $html, $matches)) { return; @@ -132,11 +185,20 @@ public function link_wikilinks($event) $names = array_map(function ($name) { return htmlspecialchars_decode($name, ENT_QUOTES); }, $matches[1]); - $existing = $this->links->existing_articles($names); + $existing = $this->links->existing_articles(array_filter($names, 'strlen')); $event['html'] = preg_replace_callback($pattern, function ($match) use ($existing) { $name = htmlspecialchars_decode($match[1], ENT_QUOTES); - $url = $this->helper->route('phpbbmodders_wiki_article', array('article' => $name)); + $section = htmlspecialchars_decode($match[2], ENT_QUOTES); + $fragment = ($section !== '') ? '#' . htmlspecialchars(\phpbbmodders\wiki\wiki\markup::anchor($section), ENT_QUOTES, 'UTF-8') : ''; + + // [[#Section]] points into the page it is on + if ($name === '') + { + return '<a class="wikilink" href="' . $fragment . '" data-wiki-article="">'; + } + + $url = $this->helper->route('phpbbmodders_wiki_article', array('article' => $name)) . $fragment; if (isset($existing[$name])) { return '<a class="wikilink" href="' . $url . '" data-wiki-article="' . $match[1] . '">'; diff --git a/language/de/link.php b/language/de/link.php index 435cec5..b8b5464 100644 --- a/language/de/link.php +++ b/language/de/link.php @@ -38,5 +38,6 @@ 'WIKI_NEW_ARTICLE_UPDATE' => 'Ein neue Version zum Artikel <strong>%1$s</strong> in der KB wartet auf Freischaltung.', 'WIKI_WATCHED_ARTICLE' => 'Ein Wiki-Artikel, den du beobachtest, hat eine neue Version', 'WIKI_WATCHED_ARTICLE_LIVE' => 'Eine neue Version des Wiki-Artikels <strong>%1$s</strong> ist jetzt aktiv.', + 'WIKI_TOC' => 'Inhaltsverzeichnis', )); diff --git a/language/en/link.php b/language/en/link.php index cefa566..5f85309 100644 --- a/language/en/link.php +++ b/language/en/link.php @@ -39,6 +39,7 @@ 'WIKI_ARTICLE_UPDATE' => 'An edit of a wiki article is awaiting approval', 'WIKI_WATCHED_ARTICLE' => 'A wiki article you watch has a new version', 'WIKI_WATCHED_ARTICLE_LIVE' => 'A new version of the wiki article <strong>%1$s</strong> is now live.', + 'WIKI_TOC' => 'Contents', 'WIKI_LINK_BUTTON' => 'Wiki link: [[Article name]] or [[Article name|link text]]', 'WIKI_LINK_NEW_ARTICLE' => 'This article doesn’t exist yet. Follow the link to create it.', 'WIKI_WHAT_LINKS_HERE' => 'What links here', diff --git a/migrations/add_wiki_markup.php b/migrations/add_wiki_markup.php new file mode 100644 index 0000000..3564159 --- /dev/null +++ b/migrations/add_wiki_markup.php @@ -0,0 +1,72 @@ +<?php +/** + * + * Wiki extension for the phpBB Forum Software package + * + * @copyright (c) 2026, phpBB Modders, https://www.phpbbmodders.com/ + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbbmodders\wiki\migrations; + +/** + * Makes the text formatter learn the headings and table of contents markup, then + * reparses the saved articles so "== Heading ==" lines in them become headings. + */ +class add_wiki_markup extends \phpbb\db\migration\container_aware_migration +{ + public static function depends_on() + { + return array( + '\phpbbmodders\wiki\migrations\add_recent_changes', + ); + } + + public function update_data() + { + return array( + array('custom', array(array($this, 'regenerate_formatter'))), + array('custom', array(array($this, 'reparse'))), + ); + } + + /** + * The text formatter keeps its tag list in a cache; rebuild it so the new tags exist. + * + * @return void + */ + public function regenerate_formatter() + { + $this->container->get('text_formatter.s9e.factory')->regenerate(); + } + + /** + * Reparse the articles, newest rows first, fifty a step. + * + * @param int $current A row id to continue from, 0 to start + * @return bool|int The next row id, or true when finished + */ + public function reparse($current = 0) + { + $reparser = new \phpbbmodders\wiki\textreparser\plugins\article_text( + $this->db, + $this->container->getParameter('core.table_prefix') . 'wiki_article' + ); + + if (empty($current)) + { + $current = $reparser->get_max_id(); + } + + $limit = 50; + $start = max(1, $current + 1 - $limit); + $end = max(1, $current); + + $reparser->reparse_range($start, $end); + + $current = $start - 1; + + return ($current === 0) ? true : $current; + } +} diff --git a/styles/prosilver/theme/style.css b/styles/prosilver/theme/style.css index 40109f4..498682e 100644 --- a/styles/prosilver/theme/style.css +++ b/styles/prosilver/theme/style.css @@ -36,3 +36,46 @@ a.wikilink-new { .forumbg ul.topiclist dd.lastpost.wiki-pending-action { width: 230px; } + +/* Headings and the table of contents written with == Heading == */ +.wiki h2.wikiheading, +.content h2.wikiheading { + margin: 1.2em 0 0.5em; + border-bottom: 1px solid #ccc; + padding-bottom: 0.2em; +} + +.wiki h3.wikiheading, +.wiki h4.wikiheading, +.wiki h5.wikiheading, +.wiki h6.wikiheading, +.content h3.wikiheading, +.content h4.wikiheading, +.content h5.wikiheading, +.content h6.wikiheading { + margin: 1em 0 0.4em; +} + +.wikitoc { + display: table; + margin: 0.8em 0; + padding: 0.6em 1em; + border: 1px solid #ccc; + background-color: rgba(128, 128, 128, 0.08); +} + +.wikitoc ul { + margin: 0.3em 0 0 1.2em; + padding: 0; + list-style: none; +} + +.wikitoc .wikitoc-title { + margin: 0; + text-align: center; +} + +.wikitoc .wikitoc-number { + margin-right: 0.3em; + opacity: 0.7; +} diff --git a/tests/unit/markup_test.php b/tests/unit/markup_test.php new file mode 100644 index 0000000..d682b4f --- /dev/null +++ b/tests/unit/markup_test.php @@ -0,0 +1,221 @@ +<?php +/** + * + * Wiki extension for the phpBB Forum Software package + * + * @copyright (c) 2026, phpBB Modders, https://www.phpbbmodders.com/ + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ + +namespace phpbbmodders\wiki\tests\unit; + +use phpbbmodders\wiki\wiki\markup; + +/** + * Headings, section anchors, the table of contents and the redirect shortcut, + * plus the parser configuration they rely on (read with the real s9e library). + */ +class markup_test extends \PHPUnit\Framework\TestCase +{ + /** @dataProvider anchors */ + public function test_an_anchor_is_made_from_the_heading_text(string $text, string $expected) + { + $this->assertSame($expected, markup::anchor($text)); + } + + public static function anchors(): array + { + return array( + 'plain' => array('History', 'History'), + 'spaces' => array('Early life and career', 'Early_life_and_career'), + 'edge spaces' => array(' Padded ', 'Padded'), + 'punctuation' => array('Tom & Jerry: "the end"?', 'Tom__Jerry:_the_end'), + 'kept marks' => array('Version 1.2 (beta), part-two', 'Version_1.2_(beta),_part-two'), + 'unicode' => array('Übersicht ✓', 'Übersicht'), + 'nothing left' => array('!!!', 'section'), + 'empty' => array('', 'section'), + ); + } + + /** @dataProvider redirects */ + public function test_a_redirect_first_line_names_its_target(string $text, string $expected) + { + $this->assertSame($expected, markup::redirect_target($text)); + } + + public static function redirects(): array + { + return array( + 'plain' => array('#REDIRECT [[Main Page]]', 'Main Page'), + 'lower case' => array('#redirect [[Main Page]]', 'Main Page'), + 'colon' => array('#REDIRECT: [[Main Page]]', 'Main Page'), + 'leading space' => array(" \n#REDIRECT [[Main Page]]\nextra", 'Main Page'), + 'section dropped' => array('#REDIRECT [[Main Page#History]]', 'Main Page'), + 'piped' => array('#REDIRECT [[Main Page|home]]', 'Main Page'), + 'not first' => array("Text first\n#REDIRECT [[Main Page]]", ''), + 'no link' => array('#REDIRECT nowhere', ''), + 'ordinary text' => array('Hello', ''), + ); + } + + private function heading(int $level, string $title): string + { + return '<h' . $level . ' class="wikiheading">' . $title . '</h' . $level . '>'; + } + + public function test_text_without_headings_or_markers_is_left_alone() + { + $html = '<p>Hello</p>'; + $this->assertSame($html, markup::finish($html, 'Contents')); + } + + public function test_headings_get_anchors_and_repeats_are_numbered() + { + $html = $this->heading(2, 'Intro') . '<p>x</p>' . $this->heading(2, 'Intro') . $this->heading(3, 'A & B'); + $out = markup::finish($html, 'Contents'); + + $this->assertStringContainsString('<h2 id="Intro" class="wikiheading">Intro</h2>', $out); + $this->assertStringContainsString('<h2 id="Intro_2" class="wikiheading">Intro</h2>', $out); + $this->assertStringContainsString('<h3 id="A__B" class="wikiheading">A & B</h3>', $out); + $this->assertStringNotContainsString('wikitoc', $out, 'three headings do not bring up a table of contents'); + } + + public function test_four_headings_bring_up_the_table_of_contents_before_the_first_one() + { + $html = '<p>Intro</p>' . $this->heading(2, 'One') . $this->heading(3, 'One A') . $this->heading(3, 'One B') . $this->heading(2, 'Two'); + $out = markup::finish($html, 'Contents'); + + $this->assertSame(1, substr_count($out, 'class="wikitoc"')); + $this->assertLessThan(strpos($out, '<h2 id="One"'), strpos($out, 'class="wikitoc"'), 'the box comes before the first heading'); + $this->assertGreaterThan(strpos($out, '<p>Intro</p>'), strpos($out, 'class="wikitoc"'), 'and after the intro'); + $this->assertStringContainsString('<strong>Contents</strong>', $out); + $this->assertStringContainsString('<a href="#One_A"><span class="wikitoc-number">1.1</span> One A</a>', $out); + $this->assertStringContainsString('<a href="#One_B"><span class="wikitoc-number">1.2</span> One B</a>', $out); + $this->assertStringContainsString('<a href="#Two"><span class="wikitoc-number">2</span> Two</a>', $out); + } + + public function test_the_table_of_contents_markup_is_balanced_when_levels_jump() + { + $html = $this->heading(2, 'A') . $this->heading(4, 'B') . $this->heading(3, 'C') . $this->heading(2, 'D'); + $out = markup::finish($html, 'Contents'); + + $dom = new \DOMDocument(); + $this->assertTrue($dom->loadXML('<root>' . $out . '</root>'), 'the box must be well-formed'); + $this->assertSame(substr_count($out, '<ul>'), substr_count($out, '</ul>')); + $this->assertSame(substr_count($out, '<li>'), substr_count($out, '</li>')); + } + + public function test_notoc_hides_the_box_and_toc_places_it() + { + $four = $this->heading(2, 'A') . $this->heading(2, 'B') . $this->heading(2, 'C') . $this->heading(2, 'D'); + $notoc = markup::finish('<span class="wikimagic" data-wiki-magic="NOTOC"></span>' . $four, 'Contents'); + $this->assertStringNotContainsString('wikitoc', $notoc); + $this->assertStringNotContainsString('wikimagic', $notoc, 'the marker is removed'); + + $placed = markup::finish($this->heading(2, 'A') . '<p>middle</p><span class="wikimagic" data-wiki-magic="TOC"></span><p>after</p>' . $this->heading(2, 'B'), 'Contents'); + $this->assertStringContainsString('class="wikitoc"', $placed, '__TOC__ shows the box for two headings'); + $this->assertGreaterThan(strpos($placed, '<p>middle</p>'), strpos($placed, 'class="wikitoc"')); + $this->assertLessThan(strpos($placed, '<p>after</p>'), strpos($placed, 'class="wikitoc"')); + $this->assertSame(1, substr_count($placed, 'class="wikitoc"')); + } + + public function test_forcetoc_shows_the_box_for_a_single_heading() + { + $out = markup::finish('<span class="wikimagic" data-wiki-magic="FORCETOC"></span>' . $this->heading(2, 'Only'), 'Contents'); + $this->assertStringContainsString('class="wikitoc"', $out); + } + + public function test_wiki_markup_is_off_unless_switched_on() + { + $this->assertFalse(markup::is_active()); + markup::set_active(true); + $this->assertTrue(markup::is_active()); + markup::set_active(false); + $this->assertFalse(markup::is_active()); + } + + private function parse(string $text, bool $wiki_markup = true): string + { + $listener = new \phpbbmodders\wiki\event\wikilink_listener( + $this->createMock(\phpbb\controller\helper::class), + $this->createMock(\phpbb\language\language::class), + $this->createMock(\phpbbmodders\wiki\wiki\links::class), + $this->createMock(\phpbbmodders\wiki\wiki\files::class) + ); + + $configurator = new \s9e\TextFormatter\Configurator; + $listener->configure_wikilink(array('configurator' => $configurator)); + extract($configurator->finalize()); + + foreach (array('WIKIHEADING', 'WIKIMAGIC') as $tag) + { + $wiki_markup ? $parser->enableTag($tag) : $parser->disableTag($tag); + } + + return $parser->parse($text); + } + + public function test_headings_of_each_level_become_tags_with_their_marks() + { + $xml = $this->parse("== Two ==\n=== Three ===\n====== Six ======"); + + $this->assertSame(3, substr_count($xml, '<WIKIHEADING'), $xml); + $this->assertStringContainsString('<WIKIHEADING marks="==" title="Two">', $xml); + $this->assertStringContainsString('<WIKIHEADING marks="===" title="Three">', $xml); + $this->assertStringContainsString('<WIKIHEADING marks="======" title="Six">', $xml); + } + + public function test_a_line_that_is_not_a_heading_stays_text() + { + foreach (array('a == b', '== unfinished', "text\n==x==y", '= one =', '=======seven=======') as $text) + { + $this->assertStringNotContainsString('<WIKIHEADING', $this->parse($text), $text); + } + } + + public function test_headings_are_text_when_wiki_markup_is_off() + { + $xml = $this->parse("== Heading ==\n__TOC__", false); + + $this->assertStringNotContainsString('<WIKIHEADING', $xml); + $this->assertStringNotContainsString('<WIKIMAGIC', $xml); + } + + public function test_magic_words_become_tags() + { + $xml = $this->parse("__NOTOC__ and __TOC__ and __FORCETOC__ and __toc__"); + + $this->assertSame(3, substr_count($xml, '<WIKIMAGIC'), $xml); + $this->assertStringContainsString('word="NOTOC"', $xml); + $this->assertStringContainsString('word="FORCETOC"', $xml); + } + + public function test_section_links_carry_their_section() + { + $xml = $this->parse('[[Article#Early life]] [[Article#History|the history]] [[#Local part]] [[Plain]] [[Plain|text]]'); + + $this->assertSame(5, substr_count($xml, '<WIKILINK'), $xml); + $this->assertStringContainsString('<WIKILINK name="Article" section="Early life">', $xml); + $this->assertStringContainsString('<WIKILINK name="Article" section="History" text="the history">', $xml); + $this->assertStringContainsString('section="Local part"', $xml); + $this->assertStringContainsString('<WIKILINK name="Plain">', $xml); + } + + public function test_broken_links_are_still_not_links() + { + foreach (array('[[]]', '[[|text]]', '[[ ]]', '[[a#]]') as $text) + { + $xml = $this->parse($text); + $this->assertStringNotContainsString('<WIKILINK name=""', $xml, $text); + } + } + + public function test_file_embeds_are_still_their_own_tag() + { + $xml = $this->parse('[[File:a.png]] [[File:b.zip|the zip]] [[Other]]'); + + $this->assertSame(2, substr_count($xml, '<WIKIFILE')); + $this->assertSame(1, substr_count($xml, '<WIKILINK')); + } +} diff --git a/textreparser/plugins/article_text.php b/textreparser/plugins/article_text.php index 6fde86c..4a1b2c4 100644 --- a/textreparser/plugins/article_text.php +++ b/textreparser/plugins/article_text.php @@ -23,4 +23,24 @@ public function get_columns() 'bbcode_uid' => 'bbcode_uid', ); } + + /** + * Reparse with the wiki's own markup on, so headings and table of contents + * markers in older articles are recognised. + * + * {@inheritdoc} + */ + public function reparse_range($min_id, $max_id, bool $force_bbcode_reparsing = false) + { + \phpbbmodders\wiki\wiki\markup::set_active(true); + + try + { + parent::reparse_range($min_id, $max_id, $force_bbcode_reparsing); + } + finally + { + \phpbbmodders\wiki\wiki\markup::set_active(false); + } + } } diff --git a/wiki/edit.php b/wiki/edit.php index 2116b89..21f787e 100644 --- a/wiki/edit.php +++ b/wiki/edit.php @@ -248,7 +248,9 @@ public function edit_article($article) } else if ($preview) // Display the preview { + \phpbbmodders\wiki\wiki\markup::set_active(true); $this->message_parser->parse($this->option['bbcode'], $this->option['url'], $this->option['smilies'], $this->option['img'], $this->option['flash'], $this->option['quote']); + \phpbbmodders\wiki\wiki\markup::set_active(false); \phpbbmodders\wiki\wiki\files::set_context($article); $this->message_parser->format_display($this->option['bbcode'], $this->option['url'], $this->option['smilies']); \phpbbmodders\wiki\wiki\files::set_context(null); @@ -267,7 +269,16 @@ public function edit_article($article) } else if ($submit) // Submit the article to database { + // A "#REDIRECT [[Article]]" first line is a shortcut for the redirect field + $redirect_target = \phpbbmodders\wiki\wiki\markup::redirect_target(htmlspecialchars_decode($this->data['article_text'], ENT_COMPAT)); + if ($redirect_target !== '' && $this->auth->acl_get('u_wiki_set_redirect') && empty($this->data['article_redirect'])) + { + $this->data['article_redirect'] = htmlspecialchars($redirect_target, ENT_COMPAT, 'UTF-8'); + } + + \phpbbmodders\wiki\wiki\markup::set_active(true); $this->message_parser->parse($this->option['bbcode'], $this->option['url'], $this->option['smilies'], $this->option['img'], $this->option['flash'], $this->option['quote']); + \phpbbmodders\wiki\wiki\markup::set_active(false); $sql_data = array( 'article_title' => $this->data['article_title'], 'article_url' => $article, diff --git a/wiki/markup.php b/wiki/markup.php new file mode 100644 index 0000000..a26845e --- /dev/null +++ b/wiki/markup.php @@ -0,0 +1,198 @@ +<?php +/** + * + * Wiki extension for the phpBB Forum Software package + * + * @copyright (c) 2026, phpBB Modders, https://www.phpbbmodders.com/ + * @license GNU General Public License, version 2 (GPL-2.0) + * + */ +namespace phpbbmodders\wiki\wiki; + +/** + * Wikipedia-style markup for wiki articles: == headings ==, a table of contents, + * and the section anchors that [[Article#Section]] links point to. + * + * The text parser only recognises this markup while an article is being saved or + * previewed (see set_active()), so forum posts keep treating "== text ==" as plain + * text. The HTML the parser leaves behind is finished here, when the article is + * shown: each heading gets an anchor, and the table of contents is built from them. + */ +class markup +{ + /** Fewest headings that bring up a table of contents by themselves, as in MediaWiki */ + const TOC_MINIMUM = 4; + + /** @var bool Whether the text parser should read wiki markup right now */ + protected static $active = false; + + /** + * Switch wiki markup on for the next parse (an article save or preview), or off. + * + * @param bool $active + * @return void + */ + public static function set_active($active) + { + self::$active = (bool) $active; + } + + /** + * @return bool + */ + public static function is_active() + { + return self::$active; + } + + /** + * The anchor a heading or a [[Article#Section]] link uses: spaces become + * underscores and anything that is not a letter, digit or one of _-.:,() is + * dropped, so the same text always gives the same anchor. + * + * @param string $text Heading text, as plain text + * @return string + */ + public static function anchor($text) + { + $anchor = preg_replace('/\s+/u', '_', trim((string) $text)); + $anchor = preg_replace('/[^\p{L}\p{N}_\-.:,()]/u', '', (string) $anchor); + $anchor = trim((string) $anchor, '_'); + + return $anchor === '' ? 'section' : $anchor; + } + + /** + * The article a "#REDIRECT [[Article]]" first line names. + * + * @param string $text The article text as typed, not HTML-escaped + * @return string The target, or '' when the text does not start with a redirect + */ + public static function redirect_target($text) + { + if (preg_match('/\A\s*#REDIRECT\s*:?\s*\[\[\s*([^\[\]|#<>"\n]{1,255}?)\s*(?:#[^\]|\n]*)?(?:\|[^\]\n]*)?\]\]/iu', (string) $text, $match)) + { + return $match[1]; + } + + return ''; + } + + /** + * Finish the headings in rendered article HTML: give each an anchor, drop the + * __TOC__, __NOTOC__ and __FORCETOC__ markers, and add the table of contents. + * + * The contents box shows after the intro, before the first heading, when the + * article has TOC_MINIMUM or more headings. __TOC__ places it elsewhere and + * shows it for any number of headings, __FORCETOC__ shows it for any number, + * and __NOTOC__ hides it. + * + * @param string $html Rendered HTML + * @param string $toc_title Heading of the contents box + * @return string + */ + public static function finish($html, $toc_title) + { + $has_headings = strpos($html, 'class="wikiheading"') !== false; + $has_magic = strpos($html, 'class="wikimagic"') !== false; + if (!$has_headings && !$has_magic) + { + return $html; + } + + $flags = array('TOC' => false, 'NOTOC' => false, 'FORCETOC' => false); + $placed = false; + $html = preg_replace_callback('/<span class="wikimagic" data-wiki-magic="(TOC|NOTOC|FORCETOC)"><\/span>/', function ($match) use (&$flags, &$placed) { + $flags[$match[1]] = true; + if ($match[1] === 'TOC' && !$placed) + { + $placed = true; + return '<!--wikitoc-->'; + } + + return ''; + }, $html); + + $headings = array(); + $used = array(); + $html = preg_replace_callback('/<h([2-6]) class="wikiheading">(.*?)<\/h\1>/s', function ($match) use (&$headings, &$used) { + $text = trim(html_entity_decode(strip_tags($match[2]), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + $base = self::anchor($text); + $anchor = $base; + for ($n = 2; isset($used[$anchor]); $n++) + { + $anchor = $base . '_' . $n; + } + $used[$anchor] = true; + $headings[] = array('level' => (int) $match[1], 'anchor' => $anchor, 'text' => $text); + + return '<h' . $match[1] . ' id="' . htmlspecialchars($anchor, ENT_QUOTES, 'UTF-8') . '" class="wikiheading">' . $match[2] . '</h' . $match[1] . '>'; + }, $html); + + $show = $headings && !$flags['NOTOC'] && ($flags['TOC'] || $flags['FORCETOC'] || count($headings) >= self::TOC_MINIMUM); + $toc = $show ? self::toc($headings, $toc_title) : ''; + + if (strpos($html, '<!--wikitoc-->') !== false) + { + return str_replace('<!--wikitoc-->', $toc, $html); + } + + if ($toc !== '') + { + $html = preg_replace('/<h[2-6] id="/', $toc . '$0', $html, 1); + } + + return $html; + } + + /** + * The contents box: nested lists, numbered 1, 1.1, 1.2, 2 and so on. + * + * @param array $headings Each: level (2 to 6), anchor, text + * @param string $title + * @return string + */ + protected static function toc(array $headings, $title) + { + $html = '<div class="wikitoc"><p class="wikitoc-title"><strong>' . htmlspecialchars($title, ENT_QUOTES, 'UTF-8') . '</strong></p>'; + $stack = array(); + $counters = array(); + + foreach ($headings as $heading) + { + $level = $heading['level']; + + // Close the lists the previous entry left open, down to this level + while ($stack && end($stack) > $level) + { + $html .= '</li></ul>'; + array_pop($stack); + array_pop($counters); + } + + if ($stack && end($stack) === $level) + { + $html .= '</li>'; + $counters[count($counters) - 1]++; + } + else + { + // A deeper level than the one open (or the first entry) starts a list + $html .= '<ul>'; + $stack[] = $level; + $counters[] = 1; + } + + $html .= '<li><a href="#' . htmlspecialchars($heading['anchor'], ENT_QUOTES, 'UTF-8') . '"><span class="wikitoc-number">' . implode('.', $counters) . '</span> ' + . htmlspecialchars($heading['text'], ENT_QUOTES, 'UTF-8') . '</a>'; + } + + while ($stack) + { + $html .= '</li></ul>'; + array_pop($stack); + } + + return $html . '</div>'; + } +} From 3893f8dc924a4ebcc207afa1a1ff260a3b01fa0e Mon Sep 17 00:00:00 2001 From: William Jacoby <bonelifer@gmail.com> Date: Mon, 12 Oct 2026 14:02:45 -0500 Subject: [PATCH 2/3] Document the headings and table of contents Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- README.md | 1 + docs/TODO.md | 12 ------------ docs/TODO/todo-wiki-markup.md | 10 ++++++++++ 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7542727..021a661 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A wiki for phpBB with version history, side-by-side compare, and a moderation qu - Files can be uploaded to an article by members who hold the **Can upload files to articles** permission. They are checked against the board's allowed file types and size limit, stored outside public view under `store/phpbbmodders_wiki/`, and read only through the wiki, which checks that the reader may see the article. In the text, `[[File:Diagram.png]]` shows an uploaded image and `[[File:notes.zip|the notes]]` links to any other file. The member who uploaded a file, or a moderator who may delete versions, can remove it, and deleting an article deletes its files. - Notifications when an edit is waiting for approval. - **Watch article** on every article page, for signed-in members: they are notified when a new version of a watched article goes live (a moderator approved an edit, or a trusted editor's edit went live directly), never about their own edit. It is a notification type in phpBB's notification settings, so members choose the bell, email or both. With the ACP setting **Watch edited articles** (on by default, under **Extensions > Wiki > Settings**), saving an edit also starts watching the article. Watches are removed with the article and with the member. +- Wikipedia-style headings: `== Heading ==` to `====== Heading ======` in an article become section headings with anchors, `[[Article#Section]]` and `[[#Section]]` link to them, and a table of contents shows after the intro when an article has four or more headings (`__TOC__` places it, `__FORCETOC__` forces it, `__NOTOC__` hides it). A first line `#REDIRECT [[Article]]` sets the redirect for members who may set redirects. This works in wiki articles only; in a forum post `== text ==` stays plain text. - The Wiki link in the board header shows the Wikipedia icon by default. Under **Extensions > Wiki > Settings** in the ACP the icon can be changed to any [Font Awesome](https://fontawesome.com/v4.7.0/icons/) icon name, or left blank for no icon. - A **Start discussion** button on each article creates its discussion topic in a forum chosen under **Extensions > Wiki > Settings** in the ACP, and links the topic to the article. It shows to members who hold the "Can change the discussion topic for an article" permission and may post in that forum, and stays hidden until a forum is chosen. - Separate permissions for viewing, editing, version history, deleting and moderating. diff --git a/docs/TODO.md b/docs/TODO.md index 6f2163a..8d9a2cb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -31,18 +31,6 @@ Still needs deciding before implementation: can build on, so it shouldn't get a free pass on the same checks normal article content goes through. -## Table of contents - -The `article_toc` column has existed in the schema since early on, but -`wiki/edit.php` always inserts an empty string into it and nothing anywhere -reads it — it's scaffolded and abandoned. Finish it: auto-generate a TOC -from the article's heading structure and render it at the top of the -article (likely only worth showing past some minimum heading count, so -short articles don't get a token one-entry TOC). - -Needs headings first, since phpBB has no heading tag; see -[Wiki markup](TODO/todo-wiki-markup.md). - ## Move/rename an article The URL slug is fixed forever once an article is created — only the title diff --git a/docs/TODO/todo-wiki-markup.md b/docs/TODO/todo-wiki-markup.md index b9c8d93..84cba98 100644 --- a/docs/TODO/todo-wiki-markup.md +++ b/docs/TODO/todo-wiki-markup.md @@ -4,6 +4,16 @@ phpBB already has BBCode, so every extra syntax is a second way to write something. This plan adds only what BBCode can't do, and only inside wiki articles. +## Built so far + +Headings (`==` to `======`), `[[Article#Section]]` and `[[#Section]]` links, the +table of contents (`__TOC__`, `__FORCETOC__`, `__NOTOC__`, automatic from four +headings) and the `#REDIRECT [[Article]]` first line are done. They are read only +while a wiki article is saved or previewed, by switching the `WIKIHEADING` and +`WIKIMAGIC` tags on in `core.text_formatter_s9e_parse_before` (see `wiki/markup.php`), +and a migration reparses saved articles. What is left below is tables, the text +styles and references (not wanted yet), `[[Category:Name]]`, and templates. + ## Scope: wiki articles only `[[Article name]]` links are safe everywhere and stay enabled in forum From 256dfd8edadf40dfb073892bc6ae0ef0af0be92d Mon Sep 17 00:00:00 2001 From: William Jacoby <bonelifer@gmail.com> Date: Mon, 12 Oct 2026 14:03:01 -0500 Subject: [PATCH 3/3] Bring the TODO notes in line with the table of contents being built Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> --- docs/TODO.md | 4 ++-- docs/TODO/todo-wiki-markup.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 8d9a2cb..bcdafac 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -80,8 +80,8 @@ A per-board style choice, set from an ACP page for this extension (admin picks "Forum-style" or "Wiki-style" for how the extension's own templates render) rather than ripping out the current look — some boards will prefer wiki content to visually match the rest of the forum, others will want it -to look like an actual wiki. Scope depends heavily on the table of contents -item above landing first, since a real wiki layout wants one. +to look like an actual wiki. A real wiki layout wants the table of contents, which now exists, as a sidebar +or floating box, so the contents box would move too. ## Per-article edit protection diff --git a/docs/TODO/todo-wiki-markup.md b/docs/TODO/todo-wiki-markup.md index 84cba98..ac438ea 100644 --- a/docs/TODO/todo-wiki-markup.md +++ b/docs/TODO/todo-wiki-markup.md @@ -55,7 +55,7 @@ Still to confirm on the test board before building on it. | Syntax | What it does | Notes | |---|---|---| -| `== Section ==` | Headings | phpBB has no heading tag. Also needed for the table of contents. | +| `== Section ==` | Headings | Built; see "Built so far". | | `[[Article#Section]]` | Link to a heading in an article | `#` isn't allowed inside `[[ ]]` today, so this shows as plain text. | | HTML tables | Tables | See below. |