diff --git a/README.md b/README.md
index 75491e2..249215b 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@ A wiki for phpBB with version history, side-by-side compare, and a moderation qu
- Tables, written as HTML in an article: `
`, `
`, `
`, ``, ``, `
`, `
` and `
`, with `colspan` and `rowspan` on a cell, and `class="infobox"` on a table, which floats it at the right of the text as an infobox (full width on a narrow screen). BBCode and `[[links]]` work inside cells. Any other attribute (`style`, `onclick`, any other `class`) is dropped, and any other element (`script`, `iframe`) stays plain text. Like headings, tables work in wiki articles only; in a forum post `
` stays plain text. After upgrading, disable and enable the extension so the board rebuilds its cache.
- Boxes the reader opens and closes: `Title...` shows just the title until it is clicked, with no JavaScript; `` starts it opened, and `` gives it the look of Wikipedia's navigation boxes (a shaded title bar over a table of links). Any other attribute is dropped. Wiki articles only, like tables.
- Centred text: `
...
` in an article centres what it holds (BBCode, links and templates work inside), as on MediaWiki. Its attributes are dropped. It works in wiki articles only, like tables.
-- HTML entities written in an article, such as ` `, ` ` or `—`, show as the characters they stand for, as on MediaWiki, in the text and in headings, link texts and link targets. `<b>` shows as "", never as markup, and an unknown entity stays as written. Like headings, this works in wiki articles only; a forum post shows entities as written. After upgrading, disable and enable the extension so the board rebuilds its cache. Articles saved earlier show their entities as written until they are saved again or reparsed with `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article`.
+- HTML entities written in an article, such as ` `, ` ` or `—`, show as the characters they stand for, as on MediaWiki, in the text and in headings, link texts and link targets. `<b>` shows as "", never as markup, and an unknown entity stays as written. Like headings, this works in wiki articles only; a forum post shows entities as written. After upgrading, disable and enable the extension so the board rebuilds its cache. Articles saved earlier are reparsed in the background after the update (see [Templates](#templates)), and then show their entities as characters.
- Templates: `{{Name}}`, `{{Name|first|second}}` and `{{Name|key=value}}` in an article include the live version of the article `Template:Name`, so a notice or infobox is written once and edited in one place. See [Templates](#templates).
- Starter templates: install ready-made hatnotes, message boxes and text helpers adapted from Wikipedia from the ACP, and optionally be told when new ones are available. See [Starter templates](#starter-templates).
- Categories: `[[Category:Name]]` in an article puts it in a category. The article lists its categories at the bottom, `/wiki/Category:Name` lists the articles in a category, and `/wiki/categories` lists every category. See [Categories](#categories).
@@ -80,6 +80,7 @@ An article
- A template can give table rows or cells (a text that starts with `
`, `
`, `
` and so on), to be called between the `
` tags of an article: `
{{Row|a|1}}{{Row|b|2}}
`.
- 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`.
+- A call can hold other calls in its values, as in `{{A|{{B|{{C}}}}}}`, and so can a value's default (`{{{name|{{B}}}}}`), 8 levels deep. **Template nesting depth** under **Extensions > Wiki > Settings** changes that, from 3 to 20; deeper calls show as written. After a change, the saved articles are updated in the background over the next few page views.
- 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.
- The page of a template shows its documentation (what is inside ``, with its calls worked out, so it can show examples), then **Template text**: the text an article gets, as it is written and not worked out, with how many articles use it. `` text is left off the page. **What links here** lists the articles that use the template.
- **Templates** at `/wiki/templates`, linked from the overview, lists every template A to Z with its short description (the article's description field), how many articles use it, and when it was last edited. A **How to use templates** box above the list explains `{{Name}}`, values and the Start templates, plus, for members who can edit, how to make one. Under each template, **Preview and how to write it** opens the call to write, with a placeholder for each value it takes, and its documentation as a preview. Templates are left out of the overview's article lists.
@@ -122,7 +123,7 @@ An article
A call that cannot be worked out, such as `{{#expr: 1/0}}`, shows a red message where it stands. An unknown `{{#function:}}` does too.
The arguments of a function are trimmed of spaces and line breaks before it works on them.
-- After upgrading, disable and enable the extension so the board rebuilds its cache. Articles saved earlier hold `{{...}}` as plain text until they are saved again or reparsed with `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article`.
+- After upgrading, disable and enable the extension so the board rebuilds its cache. The saved articles are then reparsed in the background, a batch on each of the next few page views, so `{{...}}` written before the update becomes a call. To do it at once instead, run `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article`.
### Starter templates
@@ -145,7 +146,7 @@ Write `[[Category:Name]]` anywhere in a wiki article to put it in a category. Th
- Only the live version of an article counts. A forum post keeps `[[Category:Name]]` as plain text, like the rest of the wiki markup.
- A template page counts for the categories it names outside ``, so `[[Category:Infobox templates]]` puts the template in that category. A category inside `` puts every article that uses the template in it, as on MediaWiki: `{{Stub}}` can put its articles in "Stubs". When a template changes, the articles that use it are worked out again.
- Which articles are in which category is kept in a table, worked out whenever an article's live version changes. After the update that adds it, a cron task fills it for the articles the board already has, a batch at a time on ordinary page views; until it is done, the category pages read the articles' text as before (without templates' categories, sub-categories or sort keys).
-- After upgrading, disable and enable the extension so the board rebuilds its cache. Articles saved before sort keys keep the key they were written with only once they are saved again or reparsed. Articles saved before this build keep `[[Category:Name]]` as text until they are saved again or reparsed with `php bin/phpbbcli.php reparser:reparse phpbbmodders_wiki_article`.
+- After upgrading, disable and enable the extension so the board rebuilds its cache. Articles saved before sort keys keep the key they were written with only once they are saved again or reparsed. Articles saved before categories existed are reparsed in the background after the update (see [Templates](#templates)), and then join their categories.
## Search
diff --git a/acp/main_module.php b/acp/main_module.php
index ef8e08e..460a0fa 100644
--- a/acp/main_module.php
+++ b/acp/main_module.php
@@ -11,7 +11,8 @@
namespace phpbbmodders\wiki\acp;
/**
- * The Wiki settings page: the header link's icon, and which forum "Start discussion" creates topics in.
+ * The Wiki settings page: the header link's icon, edit conflicts, the feed, how deep template calls
+ * may nest, and which forum "Start discussion" creates topics in.
*/
class main_module
{
@@ -57,7 +58,12 @@ public function main($id, $mode)
$auto_watch = $request->variable('wiki_auto_watch', 0) ? 1 : 0;
$edit_conflict = $request->variable('wiki_edit_conflict', '');
$lock_minutes = $request->variable('wiki_edit_lock_minutes', 0);
+ $nesting = $request->variable('wiki_template_nesting', 0);
+ if ($nesting < \phpbbmodders\wiki\wiki\templates::CALL_NESTING_MIN || $nesting > \phpbbmodders\wiki\wiki\templates::CALL_NESTING_MAX)
+ {
+ trigger_error($language->lang('WIKI_TEMPLATE_NESTING_INVALID', \phpbbmodders\wiki\wiki\templates::CALL_NESTING_MIN, \phpbbmodders\wiki\wiki\templates::CALL_NESTING_MAX) . adm_back_link($this->u_action), E_USER_WARNING);
+ }
if (!\phpbbmodders\wiki\wiki\edit_lock::valid_mode($edit_conflict))
{
trigger_error($language->lang('WIKI_EDIT_CONFLICT_INVALID') . adm_back_link($this->u_action), E_USER_WARNING);
@@ -101,6 +107,15 @@ public function main($id, $mode)
$config->set('wiki_edit_conflict', $edit_conflict);
$config->set('wiki_edit_lock_minutes', $lock_minutes);
+ // A new depth changes how articles are read: rebuild the parser, and reparse the
+ // saved articles in the background so they follow it
+ if ($nesting !== $phpbb_container->get('phpbbmodders.wiki.templates')->nesting())
+ {
+ $config->set('wiki_template_nesting', $nesting);
+ $phpbb_container->get('text_formatter.cache')->invalidate();
+ $phpbb_container->get('phpbbmodders.wiki.article_reparse')->queue();
+ }
+
trigger_error($language->lang('CONFIG_UPDATED') . adm_back_link($this->u_action));
}
@@ -123,6 +138,10 @@ public function main($id, $mode)
'WIKI_EDIT_LOCK_MINUTES' => $edit_lock->minutes(),
'WIKI_EDIT_LOCK_MINUTES_MIN' => \phpbbmodders\wiki\wiki\edit_lock::MINUTES_MIN,
'WIKI_EDIT_LOCK_MINUTES_MAX' => \phpbbmodders\wiki\wiki\edit_lock::MINUTES_MAX,
+ 'WIKI_TEMPLATE_NESTING' => $phpbb_container->get('phpbbmodders.wiki.templates')->nesting(),
+ 'WIKI_TEMPLATE_NESTING_MIN' => \phpbbmodders\wiki\wiki\templates::CALL_NESTING_MIN,
+ 'WIKI_TEMPLATE_NESTING_MAX' => \phpbbmodders\wiki\wiki\templates::CALL_NESTING_MAX,
+ 'S_WIKI_REPARSE_PENDING' => $phpbb_container->get('phpbbmodders.wiki.article_reparse')->pending(),
'S_WIKI_FEED_OPTIONS' => $feed_options,
'S_WIKI_AUTO_WATCH' => !isset($config['wiki_auto_watch']) || !empty($config['wiki_auto_watch']),
'WIKI_ICON' => isset($config['wiki_icon']) ? $config['wiki_icon'] : \phpbbmodders\wiki\wiki\icon::DEFAULT_ICON,
diff --git a/adm/style/acp_wiki_settings.html b/adm/style/acp_wiki_settings.html
index 4846926..f564e8b 100644
--- a/adm/style/acp_wiki_settings.html
+++ b/adm/style/acp_wiki_settings.html
@@ -27,6 +27,10 @@
diff --git a/config/services.yml b/config/services.yml
index 0f7a49f..64da5d0 100644
--- a/config/services.yml
+++ b/config/services.yml
@@ -218,6 +218,20 @@ services:
- [set_name, [cron.task.phpbbmodders.wiki.category_members_build]]
tags:
- { name: cron.task }
+ # Reparsing the saved articles after the way markup is read has changed
+ phpbbmodders.wiki.article_reparse:
+ class: phpbbmodders\wiki\wiki\article_reparse
+ arguments:
+ - '@config'
+ - '@phpbbmodders.wiki.text_reparser.article_text'
+ phpbbmodders.wiki.cron.task.article_reparse:
+ class: phpbbmodders\wiki\cron\task\article_reparse
+ arguments:
+ - '@phpbbmodders.wiki.article_reparse'
+ calls:
+ - [set_name, [cron.task.phpbbmodders.wiki.article_reparse]]
+ tags:
+ - { name: cron.task }
# Per-article edit protection: which groups may edit an article, and the page that sets it
phpbbmodders.wiki.protection:
class: phpbbmodders\wiki\wiki\protection
diff --git a/cron/task/article_reparse.php b/cron/task/article_reparse.php
new file mode 100644
index 0000000..1efc48a
--- /dev/null
+++ b/cron/task/article_reparse.php
@@ -0,0 +1,49 @@
+article_reparse = $article_reparse;
+ }
+
+ public function run()
+ {
+ $this->article_reparse->run_batch();
+ }
+
+ public function is_runnable()
+ {
+ return $this->article_reparse->pending();
+ }
+
+ /**
+ * Every request may take the next batch until all are done.
+ *
+ * @return bool
+ */
+ public function should_run()
+ {
+ return true;
+ }
+}
diff --git a/docs/TODO/todo-wiki-markup.md b/docs/TODO/todo-wiki-markup.md
index 27b19bb..5c891ca 100644
--- a/docs/TODO/todo-wiki-markup.md
+++ b/docs/TODO/todo-wiki-markup.md
@@ -163,13 +163,15 @@ string functions `#len`, `#pos`, `#rpos`, `#sub`, `#replace` and `#explode`, `#t
`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:
+out. A call written in an article, and a parameter's default value, may hold other calls 8 levels
+deep, set from 3 to 20 under Extensions > Wiki > Settings (the pattern costs under 1 ms on large
+articles even at 20); deeper calls stay text. After a change, and after the update that brought it,
+a cron task reparses the saved articles a batch at a time (see `wiki/article_reparse.php`): a
+migration cannot, as the wiki's listeners are not loaded while the extension is enabled, and a
+reparse then strips every article's wiki markup. Still to do:
- 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.
- Only if needed: 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
diff --git a/docs/images/wiki-acp-settings.png b/docs/images/wiki-acp-settings.png
index e89b94f..61f27c2 100644
Binary files a/docs/images/wiki-acp-settings.png and b/docs/images/wiki-acp-settings.png differ
diff --git a/event/wikilink_listener.php b/event/wikilink_listener.php
index 6ead3b5..99c0b3b 100644
--- a/event/wikilink_listener.php
+++ b/event/wikilink_listener.php
@@ -28,8 +28,6 @@ class wikilink_listener implements EventSubscriberInterface
const NAME_PATTERN = self::NAME_CLASS . '{1,255}';
- /** How many {{templates}} deep a template call may hold others in its parameters. */
- const TEMPLATE_NESTING = 3;
/** HTML elements a wiki article may use to write a table. */
const TABLE_ELEMENTS = array('table', 'caption', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td');
@@ -96,14 +94,15 @@ public static function getSubscribedEvents()
* The pattern for {{Name|parameters}}. s9e cannot read a recursive pattern, so
* templates inside the braces are matched down to a fixed depth.
*
+ * @param int $nesting How many levels of calls a call may hold
* @return string
*/
- protected static function template_pattern()
+ protected static function template_pattern($nesting)
{
// Anything but a brace, a {{{parameter}}}, or a brace on its own
$plain = '[^{}]++|\\{\\{\\{[^{}]*+\\}\\}\\}|\\{(?![{])|\\}(?![}])';
$inner = '(?:' . $plain . ')*?';
- for ($level = 0; $level < self::TEMPLATE_NESTING; $level++)
+ for ($level = 0; $level < \phpbbmodders\wiki\wiki\templates::nesting_in_range($nesting); $level++)
{
$inner = '(?:' . $plain . '|\\{\\{(?![{])' . $inner . '\\}\\})*?';
}
@@ -193,7 +192,8 @@ public function configure_wikilink($event)
// be, so a template can give a table row
$template->template = '';
- $configurator->Preg->match(self::template_pattern(), 'WIKITEMPLATE');
+ // The board's nesting setting; changing it rebuilds this configuration
+ $configurator->Preg->match(self::template_pattern($this->templates->nesting()), 'WIKITEMPLATE');
$tag = $configurator->tags->add('WIKILINK');
foreach (array('name' => 'decode_entities_in_name', 'section' => 'decode_entities', 'text' => 'decode_entities') as $attribute => $decode)
diff --git a/language/en/acp_wiki.php b/language/en/acp_wiki.php
index 4b929fa..a39e5ed 100644
--- a/language/en/acp_wiki.php
+++ b/language/en/acp_wiki.php
@@ -58,7 +58,12 @@
'WIKI_EDIT_LOCK_MINUTES' => 'Minutes an editor holds an article',
'WIKI_EDIT_LOCK_MINUTES_EXPLAIN' => 'How long the notice names an editor, or the lock lasts, after their last sign of work. It is extended every few minutes while their edit form is open, and when they preview, so it only runs out once they have left.',
- 'WIKI_ONLY_BBCODES' => 'BBCodes only for the wiki',
+ 'WIKI_TEMPLATE_NESTING' => 'Template nesting depth',
+ 'WIKI_TEMPLATE_NESTING_EXPLAIN' => 'How many levels of templates a template call may hold inside it, as in {{A|{{B|{{C}}}}}}. Deeper calls show as plain text. The default, 8, covers most templates copied from Wikipedia. After a change, saved articles are updated in the background over the next few page views.',
+ 'WIKI_TEMPLATE_NESTING_INVALID' => 'The template nesting depth must be a number from %1$d to %2$d.',
+ 'WIKI_REPARSE_PENDING' => 'Articles are still being updated for the last change.',
+
+ 'WIKI_ONLY_BBCODES' => 'BBCodes only for the wiki',
'WIKI_ONLY_BBCODES_EXPLAIN' => 'Tick a custom BBCode to use it only in wiki articles. It then stops working in posts, private messages and signatures, and its button shows only in the wiki editor. Text saved before you tick it keeps its formatting until it is edited. Custom BBCodes are the ones added on the Posting » BBCodes page of the ACP.',
'WIKI_ONLY_BBCODE' => 'Only in wiki articles',
'WIKI_ONLY_BBCODES_NONE' => 'This board has no custom BBCodes yet. Add them on the Posting » BBCodes page of the ACP.',
diff --git a/migrations/reparse_template_calls.php b/migrations/reparse_template_calls.php
new file mode 100644
index 0000000..1b61f2e
--- /dev/null
+++ b/migrations/reparse_template_calls.php
@@ -0,0 +1,53 @@
+table_prefix . 'wiki_article';
+ $result = $this->db->sql_query($sql);
+ $max_id = (int) $this->db->sql_fetchfield('max_id');
+ $this->db->sql_freeresult($result);
+
+ $this->config->set('wiki_reparse_next', $max_id);
+ }
+}
diff --git a/tests/unit/article_reparse_test.php b/tests/unit/article_reparse_test.php
new file mode 100644
index 0000000..1277a9c
--- /dev/null
+++ b/tests/unit/article_reparse_test.php
@@ -0,0 +1,59 @@
+ 0, 'wiki_category_members_built' => 1, 'wiki_category_members_cursor' => ''));
+ $reparser = $this->createMock(\phpbbmodders\wiki\textreparser\plugins\article_text::class);
+ $reparser->method('get_max_id')->willReturn(120);
+ $ranges = array();
+ $reparser->method('reparse_range')->willReturnCallback(function ($min, $max) use (&$ranges) {
+ $ranges[] = array($min, $max);
+ });
+ $reparse = new article_reparse($config, $reparser);
+
+ $this->assertFalse($reparse->pending());
+ $reparse->queue();
+ $this->assertTrue($reparse->pending());
+
+ $reparse->run_batch();
+ $this->assertSame(1, (int) $config['wiki_category_members_built'], 'categories are left alone until the end');
+ while ($reparse->pending())
+ {
+ $reparse->run_batch();
+ }
+
+ $this->assertSame(array(array(71, 120), array(21, 70), array(1, 20)), $ranges);
+ $this->assertSame(0, (int) $config['wiki_reparse_next']);
+ $this->assertSame(0, (int) $config['wiki_category_members_built'], 'then worked out again');
+ }
+
+ public function test_an_empty_wiki_has_nothing_to_do()
+ {
+ $config = new \phpbb\config\config(array('wiki_reparse_next' => 0));
+ $reparser = $this->createMock(\phpbbmodders\wiki\textreparser\plugins\article_text::class);
+ $reparser->method('get_max_id')->willReturn(0);
+ $reparser->expects($this->never())->method('reparse_range');
+ $reparse = new article_reparse($config, $reparser);
+
+ $reparse->queue();
+ $this->assertFalse($reparse->pending());
+ $reparse->run_batch();
+ }
+}
diff --git a/tests/unit/markup_test.php b/tests/unit/markup_test.php
index cb740dd..7f79831 100644
--- a/tests/unit/markup_test.php
+++ b/tests/unit/markup_test.php
@@ -135,14 +135,16 @@ public function test_wiki_markup_is_off_unless_switched_on()
$this->assertFalse(markup::is_active());
}
- private function parse(string $text, bool $wiki_markup = true): string
+ private function parse(string $text, bool $wiki_markup = true, ?int $nesting = null): string
{
+ $templates = $this->createMock(\phpbbmodders\wiki\wiki\templates::class);
+ $templates->method('nesting')->willReturn($nesting ?? \phpbbmodders\wiki\wiki\templates::CALL_NESTING);
$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),
- $this->createMock(\phpbbmodders\wiki\wiki\templates::class),
+ $templates,
$this->createMock(\phpbbmodders\wiki\wiki\wiki_only_bbcodes::class)
);
@@ -281,6 +283,34 @@ public function test_parser_functions_and_magic_words_are_calls_too()
$this->assertStringContainsString('name="PAGENAME"', $xml);
}
+ public function test_a_call_holds_others_eight_levels_deep()
+ {
+ // {{L0|{{L1|...{{Ln|x}}...}}}}: a call with n levels of calls inside it
+ $nested = function (int $levels): string {
+ $text = 'x';
+ for ($level = $levels; $level >= 0; $level--)
+ {
+ $text = '{{L' . $level . '|' . $text . '}}';
+ }
+
+ return $text;
+ };
+
+ $xml = $this->parse($nested(8));
+ $this->assertSame(1, substr_count($xml, 'assertStringContainsString('name="L0"', $xml);
+
+ // One level deeper, it all stays text as written
+ $xml = $this->parse($nested(9));
+ $this->assertStringNotContainsString('assertStringContainsString($nested(9), $xml);
+
+ // The board's setting moves the limit
+ $this->assertStringContainsString('name="L0"', $this->parse($nested(9), true, 9));
+ $this->assertStringNotContainsString('parse($nested(4), true, 3));
+ $this->assertStringContainsString('name="L0"', $this->parse($nested(3), true, 3));
+ }
+
public function test_a_parameter_inside_a_call_does_not_end_it()
{
$xml = $this->parse('{{A|{{{1}}}}} {{B|{{C|{{D}}}}}}');
diff --git a/tests/unit/templates_test.php b/tests/unit/templates_test.php
index 578f0fb..d11b1fe 100644
--- a/tests/unit/templates_test.php
+++ b/tests/unit/templates_test.php
@@ -89,6 +89,30 @@ public function test_a_default_can_be_another_parameter_or_a_template()
$this->assertSame('{{Other|x}}', templates::substitute('{{{a|{{Other|x}}}}}', array(), array()));
}
+ public function test_a_default_can_hold_calls_eight_levels_deep()
+ {
+ $deep = '{{A|{{B|{{C|{{D|{{E|{{F|{{G|{{H|x}}}}}}}}}}}}}}}}';
+ $this->assertSame($deep, templates::substitute('{{{a|' . $deep . '}}}', array(), array()));
+ $this->assertSame('{{#if: | {{X|{{Y|1}}}} }}', templates::substitute('{{{a|{{#if: {{{b|}}} | {{X|{{Y|1}}}} }}}}}', array(), array()), 'a parameter inside the default is filled in first');
+ $this->assertSame('given', templates::substitute('{{{a|' . $deep . '}}}', array(), array('a' => 'given')));
+
+ $too_deep = '{{{a|{{A|' . $deep . '}}}}}';
+ $this->assertSame($too_deep, templates::substitute($too_deep, array(), array()), 'nine levels stay as written');
+
+ // The board's setting moves the limit either way
+ $this->assertSame('{{A|' . $deep . '}}', templates::substitute($too_deep, array(), array(), 9));
+ $this->assertSame('{{{a|' . $deep . '}}}', templates::substitute('{{{a|' . $deep . '}}}', array(), array(), 3));
+ }
+
+ public function test_the_nesting_setting_is_kept_in_range()
+ {
+ $this->assertSame(8, templates::nesting_in_range(null), 'no setting yet: the default');
+ $this->assertSame(8, templates::nesting_in_range('0'));
+ $this->assertSame(3, templates::nesting_in_range(2));
+ $this->assertSame(12, templates::nesting_in_range('12'));
+ $this->assertSame(20, templates::nesting_in_range(99));
+ }
+
public function test_parameter_values_are_not_read_as_parameters_again()
{
$this->assertSame('{{{two}}}', templates::substitute('{{{one}}}', array(), array('one' => '{{{two}}}')));
diff --git a/wiki/article_reparse.php b/wiki/article_reparse.php
new file mode 100644
index 0000000..6df46e8
--- /dev/null
+++ b/wiki/article_reparse.php
@@ -0,0 +1,91 @@
+config = $config;
+ $this->reparser = $reparser;
+ }
+
+ /**
+ * Queue every article version, starting again from the newest if a reparse is under way.
+ *
+ * @return void
+ */
+ public function queue()
+ {
+ $this->config->set(self::CONFIG, (int) $this->reparser->get_max_id());
+ }
+
+ /**
+ * @return bool Whether some articles are still to be reparsed
+ */
+ public function pending()
+ {
+ return !empty($this->config[self::CONFIG]);
+ }
+
+ /**
+ * Reparse the next batch, and move the queue on.
+ *
+ * @return void
+ */
+ public function run_batch()
+ {
+ $end = (int) $this->config[self::CONFIG];
+ if ($end < 1)
+ {
+ return;
+ }
+
+ $start = max(1, $end + 1 - self::BATCH);
+ $this->reparser->reparse_range($start, $end);
+
+ // Only move on if nothing queued the whole list again meanwhile
+ if ($this->config->set_atomic(self::CONFIG, $end, $start - 1) && $start === 1)
+ {
+ // Done. A call that is read now, or no longer, can add or drop a category, so the
+ // category memberships are worked out again by their own cron task (see
+ // category_members::build_batch()); until then the category lists read the articles.
+ $this->config->set('wiki_category_members_cursor', '');
+ $this->config->set('wiki_category_members_built', 0);
+ }
+ }
+}
diff --git a/wiki/edit.php b/wiki/edit.php
index 21e5a3a..9788e2a 100644
--- a/wiki/edit.php
+++ b/wiki/edit.php
@@ -750,7 +750,7 @@ protected function offer_start_templates($article)
$named[$name] = $value;
}
}
- $text = $parameters ? \phpbbmodders\wiki\wiki\templates::substitute($source, $positional, $named) : $source;
+ $text = $parameters ? \phpbbmodders\wiki\wiki\templates::substitute($source, $positional, $named, $this->templates->nesting()) : $source;
}
}
diff --git a/wiki/templates.php b/wiki/templates.php
index 87acd12..74f3433 100644
--- a/wiki/templates.php
+++ b/wiki/templates.php
@@ -25,6 +25,18 @@ class templates
/** Templates inside templates stop here. */
const MAX_DEPTH = 10;
+ /**
+ * How many levels of {{calls}} a call written in the text, or a parameter's default
+ * value, may hold inside it, unless the board's setting wiki_template_nesting says
+ * otherwise. Deeper calls stay text. The patterns that find calls cannot count braces
+ * to any depth, so they are built to this depth.
+ */
+ const CALL_NESTING = 8;
+
+ /** The lowest and highest nesting the setting allows. */
+ const CALL_NESTING_MIN = 3;
+ const CALL_NESTING_MAX = 20;
+
/** Most templates expanded for one article, so a template calling itself twice cannot run away. */
const MAX_EXPANSIONS = 500;
@@ -352,7 +364,7 @@ protected function include_template($call)
// The parameters are worked out first, then put in, then the template's own calls
$positional = array_map(array($this, 'expand_text'), $positional);
$named = array_map(array($this, 'expand_text'), $named);
- $text = self::substitute(self::transclusion_text($source), $positional, $named);
+ $text = self::substitute(self::transclusion_text($source), $positional, $named, $this->nesting());
$this->stack[] = $article;
$this->level++;
@@ -984,10 +996,16 @@ public static function split_call($call)
* @param array $named Parameters by name
* @return string
*/
- public static function substitute($text, array $positional, array $named)
+ public static function substitute($text, array $positional, array $named, $nesting = self::CALL_NESTING)
{
- // The innermost {{{ }}} first, so a default can hold another parameter
- $pattern = '/\{\{\{\s*([^{}|]*?)\s*(?:\|((?:[^{}]|\{\{(?!\{)[^{}]*\}\})*))?\}\}\}/';
+ // The innermost {{{ }}} first, so a default can hold another parameter. A default may
+ // hold {{calls}}, nested $nesting deep.
+ $call = '\{\{(?!\{)[^{}]*+\}\}';
+ for ($level = 1; $level < self::nesting_in_range($nesting); $level++)
+ {
+ $call = '\{\{(?!\{)(?:[^{}]++|' . $call . ')*+\}\}';
+ }
+ $pattern = '/\{\{\{\s*([^{}|]*?)\s*(?:\|((?:[^{}]++|' . $call . ')*+))?\}\}\}/';
$replace = function ($match) use ($positional, $named) {
$key = $match[1];
if (ctype_digit($key) && isset($positional[(int) $key]))
@@ -1029,6 +1047,30 @@ public static function transclusion_text($text)
return preg_replace('#?includeonly>#i', '', $text);
}
+ /**
+ * @return int How many levels of calls a call may hold, from the board's setting
+ */
+ public function nesting()
+ {
+ return self::nesting_in_range(isset($this->config['wiki_template_nesting']) ? $this->config['wiki_template_nesting'] : self::CALL_NESTING);
+ }
+
+ /**
+ * @param mixed $value A nesting depth as set
+ * @return int The depth to use: the default when it is not a positive number, otherwise
+ * kept between CALL_NESTING_MIN and CALL_NESTING_MAX
+ */
+ public static function nesting_in_range($value)
+ {
+ $value = (int) $value;
+ if ($value < 1)
+ {
+ return self::CALL_NESTING;
+ }
+
+ return max(self::CALL_NESTING_MIN, min(self::CALL_NESTING_MAX, $value));
+ }
+
/**
* SQL condition for the live version of a template page: approved, and not replaced
* by a newer approved version.