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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ An article
- Parameters are `{{Name|value}}` by position (the first bare value is `{{{1}}}`) or `{{Name|key=value}}`; a bar inside a `[[link|text]]` or another template belongs to that.
- What is inside `<noinclude>` is left out when the template is included, and `<includeonly>` text is included without the tags, so a template page can carry its own documentation.
- The template is looked up when the article is shown, so editing it changes every article that uses it. Only a template's approved version is used.
- A template can give table rows or cells (a text that starts with `<tr>`, `<tbody>`, `<td>` and so on), to be called between the `<table>` tags of an article: `<table>{{Row|a|1}}{{Row|b|2}}</table>`.
- Limits, so one article cannot slow the board down: at most 500 template calls written in an article, 2000 calls and functions in all (including those inside templates), and about 500,000 characters of output. Past a limit the rest of the calls show "Too many templates" where they stand.
- A template can include other templates, ten levels deep at most; a template that includes itself, directly or through another template, shows an error where the loop is (it cannot stop its own recursion with `{{#if:}}`, as on Wikipedia). A call to a template that does not exist is a red link to `Template:Name`.
- The text of a template is parsed like article text, so it can use BBCode, `[[links]]`, headings and tables, and nothing an article could not.
- **What links here** on a template page lists the articles that use it.
Expand Down
7 changes: 5 additions & 2 deletions docs/TODO/todo-wiki-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,8 +158,11 @@ out. Still to do:
- Parser functions Wikipedia's templates use that are not here yet: `#titleparts`, `#rel2abs`,
`#tag`, `formatnum`, `plural`, `gender`, `#invoke` (Lua, not planned), the
`LOCAL...` and `REVISION...` magic words, and `#time` in the viewer's time zone and language.
- A cache of rendered articles, cleared when a template changes. Expansion runs on every view
today; add the cache only if a real board shows it is slow.
- A cache of rendered articles, cleared when a template changes. Expansion runs on every view.
Measured on a MySQL board: about 1 ms for each template call written in an article (a table of
400 calls to a template took about 400 ms longer than the same table written out), one extra
query for the template pages however many calls there are, and at most 500 calls and 2000
functions per article. Add the cache only if articles with hundreds of calls become common.
- Template pages show `<noinclude>` and `<includeonly>` as text; a tidier template page view.
- A template call holds other calls in its parameters three levels deep, and a default value holds
one level of `{{template}}`; deeper nesting stays text.
Expand Down
4 changes: 3 additions & 1 deletion event/wikilink_listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ public function configure_wikilink($event)
$template = $configurator->tags->add('WIKITEMPLATE');
$template->attributes->add('name');
$template->attributes->add('call');
$template->template = '<span class="wikitemplate" data-wiki-template="{@name}" data-wiki-call="{@call}"></span>';
// A <template> element, because HTML allows it between table tags where a <span> would not
// be, so a template can give a table row
$template->template = '<template class="wikitemplate" data-wiki-template="{@name}" data-wiki-call="{@call}"></template>';

$configurator->Preg->match(self::template_pattern(), 'WIKITEMPLATE');

Expand Down
42 changes: 42 additions & 0 deletions migrations/add_wiki_template_element.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?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;

/**
* Throws away the cached text formatter configuration so it is rebuilt with the
* template tag that renders as a <template> element, which a template call between
* table tags needs. Boards that already ran add_wiki_more_parser_functions would
* otherwise keep the cached <span> one, which the expansion no longer looks for.
*/
class add_wiki_template_element extends \phpbb\db\migration\container_aware_migration
{
public static function depends_on()
{
return array(
'\phpbbmodders\wiki\migrations\add_wiki_more_parser_functions',
);
}

public function update_data()
{
return array(
array('custom', array(array($this, 'purge_cache'))),
);
}

/**
* @return void
*/
public function purge_cache()
{
$this->container->get('cache')->purge();
}
}
2 changes: 1 addition & 1 deletion tests/unit/markup_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ public function test_a_template_call_renders_as_a_placeholder()
{
$html = $this->render('{{Out of date|reason=x "quoted"}}');

$this->assertStringContainsString('<span class="wikitemplate" data-wiki-template="Out of date" data-wiki-call="Out of date|reason=x &quot;quoted&quot;"></span>', $html);
$this->assertStringContainsString('<template class="wikitemplate" data-wiki-template="Out of date" data-wiki-call="Out of date|reason=x &quot;quoted&quot;"></template>', $html);
}

private const TABLE = "<table>\n<caption>Versions</caption>\n<tr><th>Name</th><th colspan=\"2\">Notes</th></tr>\n<tr><td>[b]Wiki[/b]</td><td rowspan=\"2\">1.1.0</td><td>[[Main Page]]</td></tr>\n</table>";
Expand Down
38 changes: 38 additions & 0 deletions tests/unit/parser_functions_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ protected function setUp(): void
'Template:Ping' => '{{Pong}}',
'Template:Pong' => '{{Ping}}',
'Template:Doc' => '<noinclude>Docs</noinclude>Body{{{x|}}}',
'Template:Big' => null,
'Template:Mid' => '{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}{{Big}}',
'Template:Wide' => '{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}{{Mid}}',
'Template:Deep' => '{{#if: {{{1|}}} | {{Deep|{{{1}}}}} | end}}',
'Main Page' => 'x',
);
Expand Down Expand Up @@ -274,6 +277,41 @@ public function test_an_upper_case_name_that_is_not_a_magic_word_is_still_a_temp
$this->assertSame('[[Template:NOTHING]]', $this->run_text('{{NOTHING}}'));
}

public function test_a_template_that_fans_out_is_cut_off_by_the_output_limit()
{
$this->templates->pages['Template:Big'] = str_repeat('a', 100000);

$start = microtime(true);
$out = $this->run_text('{{Wide}}');

$this->assertLessThan(2.0, microtime(true) - $start, 'it stops early');
$this->assertStringContainsString('WIKI_TEMPLATE_TOO_DEEP', $out);
$this->assertLessThanOrEqual(\phpbbmodders\wiki\wiki\templates::MAX_LENGTH, strlen($out));
}

public function test_the_limits_count_all_the_calls_of_one_article_together()
{
// Many calls that each do a little add up to the limit; the counters are not reset between them
$out = $this->run_text(str_repeat('{{#expr: 1+1}} ', 2500));

$this->assertStringContainsString('WIKI_TEMPLATE_TOO_DEEP', $out);
}

public function test_rows_and_cells_are_parsed_inside_a_table_that_is_taken_off_again()
{
$class = \phpbbmodders\wiki\wiki\templates::class;

$this->assertSame(array('<table>', '</table>'), $class::table_wrapper('<tr><td>x</td></tr>'));
$this->assertSame(array('<table>', '</table>'), $class::table_wrapper("\n <TBODY>"));
$this->assertSame(array('<table><tr>', '</tr></table>'), $class::table_wrapper('<td>x</td><td>y</td>'));
$this->assertSame(array('', ''), $class::table_wrapper('<table><tr><td>x</td></tr></table>'));
$this->assertSame(array('', ''), $class::table_wrapper('plain <tr>'));
$this->assertSame(array('', ''), $class::table_wrapper('<track>'));

$this->assertSame('<th>r</th><td>c</td>', $class::unwrap_table("<table>\n<tr><th>r</th><td>c</td></tr>\n</table>", '<table><tr>'));
$this->assertSame('<tr><td>x</td></tr>', $class::unwrap_table("<table>\n<tr><td>x</td></tr>\n</table>\n", '<table>'), 'a row keeps its own tr');
}

public function test_a_flood_of_calls_is_cut_off()
{
$out = $this->run_text(str_repeat('{{#if: x | a }}', 3000));
Expand Down
99 changes: 88 additions & 11 deletions wiki/templates.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ class templates
/** Longest text a template call may come to, in characters. */
const MAX_LENGTH = 200000;

/** All the text the calls in one article may produce, counted as they are worked out, in characters. */
const MAX_OUTPUT = 500000;

/** What an error message starts with, which {{#iferror:}} looks for. */
const ERROR_OPEN = '[color=red][b]';

Expand Down Expand Up @@ -61,12 +64,18 @@ class templates
/** @var array Names of the templates being expanded, outermost first */
protected $stack = array();

/** @var int Characters produced by calls since the outermost one began */
protected $produced = 0;

/** @var int Templates being included inside the text now being worked out */
protected $level = 0;

/** @var int Calls, functions and magic words worked out since the outermost one began */
protected $operations = 0;

/** @var array Whether an article exists, by name, for this request */
protected $exists = array();

/** @var array Raw template text by article name, for this request */
protected $sources = array();

Expand Down Expand Up @@ -103,7 +112,15 @@ public function is_expanding()
*/
public function expand_all($html)
{
$pattern = '/<span class="wikitemplate" data-wiki-template="([^"]*)" data-wiki-call="([^"]*)"><\/span>/';
// The limits are for the whole of one article, not for each call in it
if ($this->depth === 0)
{
$this->expansions = 0;
$this->operations = 0;
$this->produced = 0;
}

$pattern = '/<template class="wikitemplate" data-wiki-template="([^"]*)" data-wiki-call="([^"]*)"><\/template>/';

return preg_replace_callback($pattern, function ($match) {
return $this->expand(
Expand All @@ -122,15 +139,10 @@ public function expand_all($html)
*/
public function expand($name, $call)
{
if ($this->depth === 0)
{
$this->expansions = 0;
$this->operations = 0;
}

if ($this->depth >= self::MAX_DEPTH || ++$this->expansions > self::MAX_EXPANSIONS)
if ($this->depth >= self::MAX_DEPTH || ++$this->expansions > self::MAX_EXPANSIONS || $this->operations > self::MAX_OPERATIONS || $this->produced > self::MAX_OUTPUT)
{
return $this->render($this->error($this->language->lang('WIKI_TEMPLATE_TOO_DEEP', trim($name))));
// Once the limits are used up, the rest of the article's calls are cheap to refuse
return '<strong class="error">' . htmlspecialchars($this->language->lang('WIKI_TEMPLATE_TOO_DEEP', trim($name)), ENT_QUOTES, 'UTF-8') . '</strong>';
}

$text = trim($this->expand_text('{{' . $call . '}}'));
Expand Down Expand Up @@ -255,6 +267,24 @@ protected function evaluate($inner)
return $this->error($this->language->lang('WIKI_TEMPLATE_TOO_DEEP', trim(strtok($inner, '|'))));
}

$result = $this->evaluate_call($inner);
$this->produced += strlen($result);
if ($this->produced > self::MAX_OUTPUT)
{
return $this->error($this->language->lang('WIKI_TEMPLATE_TOO_DEEP', trim(strtok($inner, '|'))));
}

return $result;
}

/**
* Work out one call, whatever it is.
*
* @param string $inner What stood between the braces
* @return string
*/
protected function evaluate_call($inner)
{
$trimmed = ltrim($inner);
if (preg_match('/^#([a-z]+)\s*:(.*)$/is', $trimmed, $match))
{
Expand Down Expand Up @@ -805,6 +835,10 @@ protected function page_exists($article)
{
return $this->sources[$article] !== null;
}
if (isset($this->exists[$article]))
{
return $this->exists[$article];
}

$sql = 'SELECT article_id
FROM ' . $this->article_table . "
Expand All @@ -814,7 +848,7 @@ protected function page_exists($article)
$exists = (bool) $this->db->sql_fetchrow($result);
$this->db->sql_freeresult($result);

return $exists;
return $this->exists[$article] = $exists;
}

/**
Expand All @@ -828,6 +862,11 @@ protected function render($text)
$uid = $bitfield = '';
$flags = 0;

// Table rows and cells only parse inside a table, so a template that gives some
// is parsed in one, and the table is taken off again
list($open, $close) = self::table_wrapper($text);
$text = $open . $text . $close;

$was_active = markup::is_active();
markup::set_active(true);
try
Expand All @@ -843,12 +882,50 @@ protected function render($text)
$this->depth++;
try
{
return generate_text_for_display($text, $uid, $bitfield, $flags);
$html = generate_text_for_display($text, $uid, $bitfield, $flags);
}
finally
{
$this->depth--;
}

return ($open === '') ? $html : self::unwrap_table($html, $open);
}

/**
* What to put around text that starts with a table row or cell so it parses.
*
* @param string $text
* @return array The opening and closing text, empty when none is needed
*/
public static function table_wrapper($text)
{
if (preg_match('/^\s*<(tr|tbody|thead|tfoot|caption)[\s>]/i', $text))
{
return array('<table>', '</table>');
}
if (preg_match('/^\s*<(td|th)[\s>]/i', $text))
{
return array('<table><tr>', '</tr></table>');
}

return array('', '');
}

/**
* Take the table put round rows or cells by table_wrapper() off the rendered text.
*
* @param string $html The rendered text
* @param string $open The opening text table_wrapper() gave
* @return string
*/
public static function unwrap_table($html, $open)
{
$row = ($open === '<table><tr>') ? '(?:<tr>\s*)' : '';
$html = preg_replace('#^\s*<table>\s*' . $row . '#i', '', $html);
$row = ($open === '<table><tr>') ? '(?:</tr>\s*)' : '';

return preg_replace('#\s*' . $row . '</table>\s*$#i', '', $html);
}

/**
Expand Down
Loading