Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ A wiki for phpBB with version history, side-by-side compare, and a moderation qu

- A wiki at `/wiki`, with an overview page listing its articles.
- Start an article from the overview page's **New article title** box, or by linking to `/wiki/<article-name>` and following the link.
- Link to another article by writing `[[Article name]]`, in articles and in forum posts; links to articles that don't exist yet are shown in red and open the editor. Each article lists the articles that link to it under **What links here**. See [Linking between articles](#linking-between-articles).
- Every edit is saved as a new version; compare any two versions side by side.
- Edits can wait for approval: moderators get a **Pending articles** list with Approve and Reject. Approving a version older than the live one is blocked, so a stale draft can't overwrite newer content.
- Moderators can take an article offline or delete it, and articles can be made sticky or set to redirect.
Expand All @@ -34,6 +35,27 @@ an active article offline or deleting it entirely:

[![Moderator controls dropdown: set article inactive, delete article](docs/images/wiki-moderator-controls.png)](docs/images/wiki-moderator-controls.png)

## Linking between articles

Write an article's name in double square brackets to link to it, in wiki articles and in forum posts:

- `[[Installation]]` links to the article **Installation**.
- `[[Installation|how to install]]` links to the same article but shows *how to install*.

Links to articles that don't exist yet are shown in red, and following one opens the editor to create that article. The **Wiki link** button in the editor toolbar inserts the brackets. Inside a `[code]` block the brackets stay plain text.

Below each article, **What links here** lists the other articles whose current version links to it.

<table>
<tr>
<td align="center"><a href="docs/images/wiki-article-links.png"><img src="docs/images/wiki-article-links.png" width="420" alt="An article with links to other articles; links to articles that don't exist yet are red"></a><br>Links, with missing articles in red</td>
<td align="center"><a href="docs/images/wiki-what-links-here.png"><img src="docs/images/wiki-what-links-here.png" width="280" alt="An article with its What links here list"></a><br>What links here</td>
<td align="center"><a href="docs/images/wiki-link-button.png"><img src="docs/images/wiki-link-button.png" width="280" alt="The Wiki link button in the editor toolbar, outlined"></a><br>The editor button (outlined)</td>
</tr>
</table>

Articles and posts written before this feature existed get their links the next time they are edited. To convert them all at once, run phpBB's reparser from the board's folder: `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article` for articles and `php bin/phpbbcli.php reparser:reparse post_text` for forum posts.

## Requirements

- phpBB 3.3.19 or later (also tested on phpBB 4.0)
Expand Down
14 changes: 14 additions & 0 deletions config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,20 @@ services:
- '%tables.wiki.article%'
- '%core.root_path%'
- '%core.php_ext%'
- '@phpbbmodders.wiki.links'
phpbbmodders.wiki.links:
class: phpbbmodders\wiki\wiki\links
arguments:
- '@dbal.conn'
- '%tables.wiki.article%'
phpbbmodders.wiki.wikilink_listener:
class: phpbbmodders\wiki\event\wikilink_listener
arguments:
- '@controller.helper'
- '@language'
- '@phpbbmodders.wiki.links'
tags:
- { name: event.listener }

phpbbmodders.wiki.notification.type.article_edit:
class: phpbbmodders\wiki\notification\article_edit
Expand Down
12 changes: 1 addition & 11 deletions docs/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,6 @@ 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).

## Internal wiki links

A lightweight `[[Article Title]]`-style shortcut (MediaWiki's signature
syntax) instead of requiring a full `[url=...]` BBCode every time you want
to link one article to another. Would need a custom BBCode or a message-parse
step that resolves `[[...]]` to the right article URL, including handling
the "target article doesn't exist yet" case (MediaWiki renders those as a
distinct "red link" style).

## Move/rename an article

The URL slug is fixed forever once an article is created — only the title
Expand Down Expand Up @@ -107,8 +98,7 @@ 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
and internal wiki links items above landing first, since a real wiki
layout wants both.
item above landing first, since a real wiki layout wants one.

## Recent Changes feed

Expand Down
Binary file added docs/images/wiki-article-links.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/wiki-article-view.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/wiki-compare.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/wiki-edit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/wiki-link-button.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/wiki-moderator-controls.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/wiki-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/wiki-pending-articles.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/wiki-what-links-here.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
126 changes: 126 additions & 0 deletions event/wikilink_listener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?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\event;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
* [[Article name]] and [[Article name|link text]] in wiki articles and
* forum posts.
*
* The text formatter turns the markup into a WIKILINK tag that renders as
* <a class="wikilink" data-wiki-article="...">. The link address is added
* here after rendering, not in the tag's template, so it comes from the
* wiki's own route; links to articles that don't exist yet are marked so
* they can be styled and lead to the editor.
*/
class wikilink_listener implements EventSubscriberInterface
{
/** Characters an article name in [[ ]] may not contain. */
const NAME_PATTERN = '[^\[\]|<>"#?%\/\\\\\n]{1,255}';

/** @var \phpbb\controller\helper */
protected $helper;

/** @var \phpbb\language\language */
protected $language;

/** @var \phpbbmodders\wiki\wiki\links */
protected $links;

/**
* @param \phpbb\controller\helper $helper Controller helper object
* @param \phpbb\language\language $language Language object
* @param \phpbbmodders\wiki\wiki\links $links Wiki links service
*/
public function __construct(\phpbb\controller\helper $helper, \phpbb\language\language $language, \phpbbmodders\wiki\wiki\links $links)
{
$this->helper = $helper;
$this->language = $language;
$this->links = $links;
}

/**
* {@inheritdoc}
*/
public static function getSubscribedEvents()
{
return array(
'core.text_formatter_s9e_configure_after' => 'configure_wikilink',
'core.text_formatter_s9e_render_after' => 'link_wikilinks',
);
}

/**
* Teach the text formatter [[Article name]] and [[Article name|text]].
*
* @param \phpbb\event\data $event The event object
* @return void
*/
public function configure_wikilink($event)
{
$configurator = $event['configurator'];
if (isset($configurator->tags['WIKILINK']))
{
return;
}

$tag = $configurator->tags->add('WIKILINK');
$tag->attributes->add('name');
$tag->attributes->add('text')->required = false;
$tag->template = '<a class="wikilink" data-wiki-article="{@name}">'
. '<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*(?<name>' . self::NAME_PATTERN . '?)\s*(?:\|\s*(?<text>[^\[\]\n]{1,255}?)\s*)?\]\]/',
'WIKILINK'
);
}

/**
* Point rendered wiki links at their articles, and mark the ones whose
* article doesn't exist yet.
*
* @param \phpbb\event\data $event The event object
* @return void
*/
public function link_wikilinks($event)
{
$html = $event['html'];
if (strpos($html, 'class="wikilink"') === false)
{
return;
}

$pattern = '/<a class="wikilink" data-wiki-article="([^"]*)">/';
if (!preg_match_all($pattern, $html, $matches))
{
return;
}

$names = array_map(function ($name) {
return htmlspecialchars_decode($name, ENT_QUOTES);
}, $matches[1]);
$existing = $this->links->existing_articles($names);

$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));
if (isset($existing[$name]))
{
return '<a class="wikilink" href="' . $url . '" data-wiki-article="' . $match[1] . '">';
}

return '<a class="wikilink wikilink-new" href="' . $url . '" data-wiki-article="' . $match[1] . '" title="'
. htmlspecialchars($this->language->lang('WIKI_LINK_NEW_ARTICLE'), ENT_COMPAT, 'UTF-8') . '">';
}, $html);
}
}
3 changes: 3 additions & 0 deletions language/en/link.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,5 +36,8 @@
$lang = array_merge($lang, array(
'WIKI' => 'Wiki',
'WIKI_NEW_ARTICLE_UPDATE' => 'A new version of the article <strong>%1$s</strong> in the Wiki is awaiting approval.',
'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',

));
11 changes: 11 additions & 0 deletions styles/prosilver/template/article_body.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ <h3>{L_SOURCES}</h3>
</div>
<!-- ENDIF -->

<!-- IF .what_links_here -->
<h3>{L_WIKI_WHAT_LINKS_HERE}</h3>
<div class="content">
<ul>
<!-- BEGIN what_links_here -->
<li><a href="{what_links_here.U_ARTICLE}">{what_links_here.TITLE}</a></li>
<!-- END what_links_here -->
</ul>
</div>
<!-- ENDIF -->

<!-- IF EDIT_REASON -->
<div class="panel bg2">
<div class="inner wiki">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<button type="button" class="button button-icon-only bbcode-wikilink" name="addwikilink" value="[[ ]]" onclick="bbfontstyle('[[', ']]')" title="{L_WIKI_LINK_BUTTON}">
<i class="icon fa-book fa-fw" aria-hidden="true"></i><span class="sr-only">{L_WIKI_LINK_BUTTON}</span>
</button>
5 changes: 5 additions & 0 deletions styles/prosilver/theme/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,8 @@ th.big{width:300px;}
body[class~="section-app/kb/"] ul.topiclist dd {
padding-left: 1em !important;
}

/* [[ ]] links to wiki articles that don't exist yet */
a.wikilink-new {
color: #bc2a4d;
}
128 changes: 128 additions & 0 deletions wiki/links.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
<?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;

/**
* Links between wiki articles, written as [[Article name]] or
* [[Article name|link text]]: which linked articles exist, and which
* articles link to a given one.
*/
class links
{
/** @var \phpbb\db\driver\driver_interface */
protected $db;

/** @var string */
protected $article_table;

/**
* @param \phpbb\db\driver\driver_interface $db Database object
* @param string $article_table Wiki article table
*/
public function __construct(\phpbb\db\driver\driver_interface $db, $article_table)
{
$this->db = $db;
$this->article_table = $article_table;
}

/**
* Which of the given article names have an approved version.
*
* @param array $names Article names (their URL part), as written in [[ ]]
* @return array The names that exist, as keys
*/
public function existing_articles(array $names)
{
$names = array_values(array_unique(array_filter($names, 'strlen')));
if (empty($names))
{
return array();
}

$sql = 'SELECT DISTINCT article_url
FROM ' . $this->article_table . '
WHERE article_approved = 1
AND ' . $this->db->sql_in_set('article_url', $names);
$result = $this->db->sql_query($sql);
$existing = array();
while ($row = $this->db->sql_fetchrow($result))
{
$existing[$row['article_url']] = true;
}
$this->db->sql_freeresult($result);

return $existing;
}

/**
* Articles whose live version (the newest approved one) links to an article.
*
* @param string $name The linked article's name (its URL part)
* @return array Rows of article_url and article_title, sorted by title
*/
public function what_links_here($name)
{
// Links are stored in the parsed text as <WIKILINK name="...">.
$needle = '<WIKILINK name="' . htmlspecialchars($name, ENT_COMPAT, 'UTF-8') . '"';
$sql = 'SELECT article_id, article_url, article_title, article_last_edit
FROM ' . $this->article_table . '
WHERE article_approved = 1
AND article_url <> \'' . $this->db->sql_escape($name) . '\'
AND article_text ' . $this->db->sql_like_expression($this->db->get_any_char() . $needle . $this->db->get_any_char());
$result = $this->db->sql_query($sql);
$candidates = array();
while ($row = $this->db->sql_fetchrow($result))
{
$candidates[(int) $row['article_id']] = $row;
}
$this->db->sql_freeresult($result);

if (empty($candidates))
{
return array();
}

// Keep only matches that are their article's live version.
$urls = array_values(array_unique(array_column($candidates, 'article_url')));
$sql = 'SELECT article_id, article_url
FROM ' . $this->article_table . '
WHERE article_approved = 1
AND ' . $this->db->sql_in_set('article_url', $urls) . '
ORDER BY article_last_edit DESC, article_id DESC';
$result = $this->db->sql_query($sql);
$live = array();
while ($row = $this->db->sql_fetchrow($result))
{
if (!isset($live[$row['article_url']]))
{
$live[$row['article_url']] = (int) $row['article_id'];
}
}
$this->db->sql_freeresult($result);

$linking = array();
foreach ($live as $article_id)
{
if (isset($candidates[$article_id]))
{
$linking[] = array(
'article_url' => $candidates[$article_id]['article_url'],
'article_title' => $candidates[$article_id]['article_title'],
);
}
}
usort($linking, function ($a, $b) {
return strcasecmp($a['article_title'], $b['article_title']);
});

return $linking;
}
}
Loading
Loading