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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,22 @@ An article
| `{{#iferror: test \| error \| ok}}` | `error` when `test` is one of the red messages below (a failed `#expr`, a template loop, an unknown function), otherwise `ok`, or `test` itself when `ok` is left out |
| `{{#time: j F Y, H:i \| date}}` | a date in a format, in UTC: `Y y n m M F d j D l N w z W t L H G h g i s a A U c r e` as in PHP's `date()`, `"quoted text"` and `\c` as written; `date` is anything PHP reads (`2026-10-12`, `@1791500000`, `yesterday`, `+1 week`), and the time now when left out |
| `{{CURRENTYEAR}}`, `{{CURRENTMONTH}}`, `{{CURRENTMONTHNAME}}`, `{{CURRENTMONTHABBREV}}`, `{{CURRENTDAY}}`, `{{CURRENTDAY2}}`, `{{CURRENTDAYNAME}}`, `{{CURRENTDOW}}`, `{{CURRENTWEEK}}`, `{{CURRENTHOUR}}`, `{{CURRENTTIME}}`, `{{CURRENTTIMESTAMP}}` | the date and time now, in UTC, with English month and day names |
| `{{LOCALYEAR}}`, `{{LOCALTIME}}` and the other `LOCAL...` words | the same, in the board's time zone |
| `{{#timel: j F Y, H:i \| date}}` | `#time` in the reader's time zone, with month and day names in the reader's language |
| `{{REVISIONID}}`, `{{REVISIONUSER}}`, `{{REVISIONTIMESTAMP}}`, `{{REVISIONYEAR}}`, `{{REVISIONMONTH}}`, `{{REVISIONMONTH1}}`, `{{REVISIONDAY}}`, `{{REVISIONDAY2}}` | the article's current approved version: its number, who saved it and when (UTC); empty before the article is first approved |
| `{{#len: text}}` | the number of characters |
| `{{#pos: text \| find \| from}}`, `{{#rpos: text \| find}}` | where `find` first or last occurs, counted from 0; `#pos` gives nothing and `#rpos` gives `-1` when it is not there; an empty `find` is a space |
| `{{#sub: text \| start \| length}}` | part of `text`; a negative `start` counts from the end and a negative `length` leaves that many off the end |
| `{{#replace: text \| find \| with}}` | every `find` swapped for `with`; an empty `find` is a space, so `{{#replace: text \|\|  }}` keeps `text` on one line |
| `{{#explode: text \| separator \| position \| limit}}` | one piece of `text` cut at `separator`, counted from 0 (negative from the end); an empty `separator` is a space |
| `{{padleft: text \| length \| fill}}`, `{{padright: text \| length \| fill}}` | `text` filled up to `length` with `fill` (default `0`) |
| `{{urlencode: text}}`, `{{anchorencode: text}}` | `text` as a web address part, and as a heading anchor |
| `{{#titleparts: A/B/C \| count \| first}}` | parts of a name split at `/`: `count` parts (all when 0, a negative number leaves that many off the end) from part `first` (1 is the first, negative counts from the end) |
| `{{#rel2abs: ../Other \| base}}` | a name worked out from `./`, `../` and `/` against `base`, or the article being shown |
| `{{formatnum: 1234567}}`, `{{formatnum: 1,234 \| R}}` | `1,234,567`; `R` takes the commas out again |
| `{{plural: count \| one \| many}}` | `one` when `count` is 1 or -1, otherwise `many` |
| `{{gender: name \| he \| she \| they}}` | always the third form (or the first when there is only one), as phpBB keeps no gender |
| `{{#tag: center \| text \| attribute=value}}` | an HTML tag written out, parsed with the article, so only the HTML articles may use works (tables, `center`, `details` and the others above); anything else shows as text |

A call that cannot be worked out, such as `{{#expr: 1/0}}`, shows a red message where it stands. An unknown `{{#function:}}` does too.

Expand Down
1 change: 1 addition & 0 deletions config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,7 @@ services:
- '@dbal.conn'
- '@language'
- '%tables.wiki.article%'
- '@user'
phpbbmodders.wiki.wikilink_listener:
class: phpbbmodders\wiki\event\wikilink_listener
arguments:
Expand Down
14 changes: 7 additions & 7 deletions docs/TODO/todo-wiki-markup.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,17 +152,17 @@ like *Version*, *Author* and *Requires* that each article fills in.

Built so far: the syntax and parameters described above, the `Template:` pages, expansion on
display, the loop and depth limits, **What links here** for templates, and the parser functions
`#if`, `#ifeq`, `#switch`, `#ifexist`, `#expr`, `#ifexpr`, `#iferror`, `#time`, the string functions
`#len`, `#pos`, `#rpos`, `#sub`, `#replace` and `#explode`, `lc`, `uc`, `lcfirst`, `ucfirst`,
`padleft`, `padright`, `urlencode`, `anchorencode`, the magic words `PAGENAME`, `FULLPAGENAME`,
`NAMESPACE` and `SITENAME`, and the `CURRENT...` date words (see `wiki/templates.php`,
`#if`, `#ifeq`, `#switch`, `#ifexist`, `#expr`, `#ifexpr`, `#iferror`, `#time`, `#timel`, the
string functions `#len`, `#pos`, `#rpos`, `#sub`, `#replace` and `#explode`, `#titleparts`,
`#rel2abs`, `#tag`, `lc`, `uc`, `lcfirst`, `ucfirst`, `padleft`, `padright`, `urlencode`,
`anchorencode`, `formatnum`, `plural`, `gender`, the magic words `PAGENAME`, `FULLPAGENAME`,
`NAMESPACE` and `SITENAME`, and the `CURRENT...`, `LOCAL...` and `REVISION...` words (see `wiki/templates.php`,
`wiki/expression.php` and `wiki/text_tools.php`). Calls are worked out as text, before the article
is parsed, so a parameter can be another call and only the chosen branch of a function is worked
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.
- Not planned: `#invoke` (Lua modules). `plural` uses the English rule and `gender` always gives the
neutral form, since phpBB keeps no gender.
- Deeper nesting: a template call holds other calls in its parameters only three levels deep, and a
default value holds only one level of `{{template}}`; anything deeper stays text. Lift both limits,
keeping a cap so one article still cannot slow the board down.
Expand Down
2 changes: 1 addition & 1 deletion event/wikilink_listener.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ protected static function template_pattern()
$inner = '(?:' . $plain . '|\\{\\{(?![{])' . $inner . '\\}\\})*?';
}

return '/(?<![{])\\{\\{(?![{])(?=\\s*(?<name>#[a-z]+(?=\\s*:)|(?:lc|uc|lcfirst|ucfirst|urlencode|anchorencode|padleft|padright)(?=\\s*:)|[^|{}\\[\\]<>"#\\s][^|{}\\[\\]<>"\\n]{0,254}?(?=\\s*(?:\\||\\}\\}))))(?<call>' . $inner . ')\\}\\}(?![}])/';
return '/(?<![{])\\{\\{(?![{])(?=\\s*(?<name>#[a-z][a-z0-9]*(?=\\s*:)|(?:lc|uc|lcfirst|ucfirst|urlencode|anchorencode|padleft|padright|formatnum|plural|gender)(?=\\s*:)|[^|{}\\[\\]<>"#\\s][^|{}\\[\\]<>"\\n]{0,254}?(?=\\s*(?:\\||\\}\\}))))(?<call>' . $inner . ')\\}\\}(?![}])/';
}

/**
Expand Down
76 changes: 75 additions & 1 deletion tests/unit/parser_functions_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,22 @@ class parser_functions_test extends \PHPUnit\Framework\TestCase
/** @var \phpbbmodders\wiki\wiki\templates */
protected $templates;

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

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

protected function setUp(): void
{
$language = $this->createMock(\phpbb\language\language::class);
$language->method('lang')->willReturnCallback(function ($key, ...$args) {
return $key . ($args ? '(' . implode(',', $args) . ')' : '');
});

$config = new \phpbb\config\config(array('sitename' => 'Test Board'));
$config = new \phpbb\config\config(array('sitename' => 'Test Board', 'board_timezone' => 'Pacific/Auckland'));
$this->language = $language;
$this->config = $config;

$this->templates = new class($config, $this->createMock(\phpbb\db\driver\driver_interface::class), $language, 'phpbb_wiki_article') extends \phpbbmodders\wiki\wiki\templates {
/** @var array Template page text by article name */
Expand Down Expand Up @@ -67,6 +75,14 @@ protected function current_article()
return $this->article;
}

/** @var array The live version the REVISION words read */
public $revision = array('article_id' => 42, 'article_last_edit' => 1772892309, 'username' => 'Editor');

protected function live_revision($article)
{
return $this->revision;
}

/** Saturday 7 March 2026, 14:05:09 UTC */
protected function now()
{
Expand Down Expand Up @@ -384,4 +400,62 @@ public function test_a_flood_of_calls_is_cut_off()

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

public function test_titleparts_and_rel2abs()
{
$this->templates->article = 'Guide/Install/Linux';

$this->assertSame('Guide/Install', $this->run_text('{{#titleparts: Guide/Install/Linux | 2 }}'));
$this->assertSame('Install', $this->run_text('{{#titleparts: Guide/Install/Linux | 1 | 2 }}'));
$this->assertSame('Guide/Install/Windows', $this->run_text('{{#rel2abs: ../Windows }}'));
$this->assertSame('Other/Page', $this->run_text('{{#rel2abs: ../Page | Other/X }}'));
}

public function test_formatnum_plural_and_gender()
{
$this->assertSame('1,234', $this->run_text('{{formatnum: 1234 }}'));
$this->assertSame('1234', $this->run_text('{{formatnum: 1,234 | R }}'));
$this->assertSame('3 pages', $this->run_text('3 {{plural: 3 | page | pages }}'));
$this->assertSame('1 page', $this->run_text('1 {{plural: {{#expr: 2 - 1}} | page | pages }}'));
$this->assertSame('they', $this->run_text('{{gender: Someone | he | she | they }}'));
$this->assertSame('he', $this->run_text('{{gender: Someone | he }}'));
}

public function test_tag_writes_an_html_tag()
{
$this->assertSame('<center>Hi</center>', $this->run_text('{{#tag: center | Hi }}'));
$this->assertSame('<td colspan="2">x</td>', $this->run_text('{{#tag: td | x | colspan = 2 }}'));
$this->assertSame('<span title="a &quot;b">x</span>', $this->run_text('{{#tag: span | x | title=a "b}}'));
$this->assertSame('<br></br>', $this->run_text('{{#tag: br }}'));
$this->assertStringContainsString('WIKI_TEMPLATE_UNKNOWN_FUNCTION', $this->run_text('{{#tag: bad name | x }}'));
}

public function test_local_and_revision_words()
{
// 14:05 UTC on 7 March is 03:05 on 8 March in Auckland (daylight time)
$this->assertSame('8', $this->run_text('{{LOCALDAY}}'));
$this->assertSame('7', $this->run_text('{{CURRENTDAY}}'));
$this->assertSame('03', $this->run_text('{{LOCALHOUR}}'));

$this->assertSame('42', $this->run_text('{{REVISIONID}}'));
$this->assertSame('Editor', $this->run_text('{{REVISIONUSER}}'));
$this->assertSame('2026', $this->run_text('{{REVISIONYEAR}}'));
$this->assertSame('20260307140509', $this->run_text('{{REVISIONTIMESTAMP}}'));

$this->templates->revision = array();
$this->assertSame('', $this->run_text('{{REVISIONID}}'), 'an article not saved yet');
}

public function test_timel_uses_the_readers_zone_and_language()
{
$this->language->method('lang_raw')->willReturn(array('Saturday' => 'Samstag', 'Sunday' => 'Sonntag', 'March' => 'M盲rz'));
$user = $this->createMock(\phpbb\user::class);
$user->timezone = new \DateTimeZone('Asia/Tokyo');
$class = get_class($this->templates);
$templates = new $class($this->config, $this->createMock(\phpbb\db\driver\driver_interface::class), $this->language, 'phpbb_wiki_article', $user);

// 14:05 UTC is 23:05 in Tokyo, the same Saturday
$this->assertSame('Samstag 23:05, 7 M盲rz', $templates->expand_text('{{#timel: l H:i, j F | now}}'));
$this->assertSame('Saturday 14:05', $templates->expand_text('{{#time: l H:i | now}}'), '#time stays in UTC and English');
}
}
47 changes: 47 additions & 0 deletions tests/unit/text_tools_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,51 @@ public function test_positions_count_characters_not_bytes()
$this->assertSame('3', text_tools::rpos('茅 b c', ''));
$this->assertSame('x', text_tools::sub('茅x茅', 1, 1));
}

public function test_titleparts_takes_parts_of_a_name()
{
$this->assertSame('A/B/C', text_tools::titleparts('A/B/C'));
$this->assertSame('A/B', text_tools::titleparts('A/B/C', 2));
$this->assertSame('B', text_tools::titleparts('A/B/C', 1, 2));
$this->assertSame('C', text_tools::titleparts('A/B/C', 0, -1));
$this->assertSame('A', text_tools::titleparts('A/B/C', -2));
$this->assertSame('', text_tools::titleparts('A/B/C', 1, 5));
}

public function test_rel2abs_works_out_a_path()
{
$this->assertSame('A/B/C', text_tools::rel2abs('./C', 'A/B'));
$this->assertSame('A/C', text_tools::rel2abs('../C', 'A/B'));
$this->assertSame('A/B/C', text_tools::rel2abs('/C', 'A/B'));
$this->assertSame('X/Y', text_tools::rel2abs('X/Y', 'A/B'));
$this->assertSame('A', text_tools::rel2abs('..', 'A/B'));
$this->assertSame('', text_tools::rel2abs('../../..', 'A/B'));
}

public function test_formatnum_adds_and_removes_separators()
{
$this->assertSame('1,234,567', text_tools::formatnum('1234567'));
$this->assertSame('-1,234.5', text_tools::formatnum('-1234.5'));
$this->assertSame('999', text_tools::formatnum('999'));
$this->assertSame('1234567', text_tools::formatnum('1,234,567', 'R'));
$this->assertSame('abc', text_tools::formatnum('abc'));
}

public function test_plural_picks_the_english_form()
{
$this->assertSame('item', text_tools::plural('1', array('item', 'items')));
$this->assertSame('item', text_tools::plural('-1', array('item', 'items')));
$this->assertSame('items', text_tools::plural('2', array('item', 'items')));
$this->assertSame('items', text_tools::plural('0', array('item', 'items')));
$this->assertSame('item', text_tools::plural('5', array('item')));
$this->assertSame('items', text_tools::plural('1,000', array('item', 'items')));
}

public function test_date_words_can_take_a_time_zone()
{
$now = gmmktime(23, 30, 0, 3, 7, 2026);

$this->assertSame('7', text_tools::date_word('CURRENTDAY', $now));
$this->assertSame('8', text_tools::date_word('CURRENTDAY', $now, new \DateTimeZone('Europe/Berlin')));
}
}
Loading
Loading