diff --git a/README.md b/README.md
index da85802..c5be871 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@ See [For AI agents and LLMs](#for-ai-agents-and-llms).
- [Usage documentation](https://professional.wiki/en/extension/native-markdown#Usage)
- [Installation](#installation)
- [Configuration](#configuration)
+- [Template transclusion](#template-transclusion)
- [For AI agents and LLMs](#for-ai-agents-and-llms)
- [Comparison with other Markdown extensions](#comparison-with-other-markdown-extensions)
- [Development](#development)
@@ -92,6 +93,7 @@ wikitext and Markdown (in both directions) via `Special:ChangeContentModel`.
| `$wgNativeMarkdownEverywhere` | `false` | New pages everywhere default to Markdown, the "Markdown wiki" mode (see exclusions below) |
| `$wgNativeMarkdownSuffixDetection` | `false` | New pages whose title ends in `.md` default to Markdown, in every namespace |
| `$wgNativeMarkdownAllowExternalImages` | `false` | Embed external `` images; when off they render as plain links |
+| `$wgNativeMarkdownTemplateTransclusion` | `false` | Expand `{{template}}` calls on Markdown pages; when off they stay literal text (see [Template transclusion](#template-transclusion)) |
`$wgNativeMarkdownEverywhere` covers the whole prose wiki, but deliberately leaves some pages as wikitext: the
discussion (Talk) namespaces, where signatures and threading depend on wikitext; the Template and MediaWiki
@@ -100,6 +102,46 @@ or JSON namespace). Titles ending in `.css`, `.js` or `.json` never default to M
reserves those for code pages. External links honor the core `$wgNoFollowLinks` setting. Input size is bounded
by core's `$wgMaxArticleSize`.
+## Template transclusion
+
+By default `{{...}}` is literal text on a Markdown page. Set
+`$wgNativeMarkdownTemplateTransclusion = true` to expand template calls instead, so Markdown pages can reuse a
+wiki's shared infoboxes, citations and navboxes:
+
+```markdown
+{{Infobox person
+| name = Ada Lovelace
+| born = 1815
+}}
+
+Ada Lovelace was an English **mathematician**, regarded as the first computer programmer.
+```
+
+Expansion delegates to MediaWiki's own parser, so template dependencies are tracked (editing a template
+reparses the pages that transclude it), recursion and size limits apply, and the output is sanitized exactly
+as wikitext is. Because it is the real parser, enabling this **also enables parser functions and magic words**
+inside the braces (`{{#if:}}`, `{{PAGENAME}}`, and so on).
+
+Placement follows the split between block and inline content:
+
+- A call on its **own line** produces block output, so an infobox table renders as a block rather than being
+ wrapped in a paragraph. It may span multiple lines, including blank parameter lines, until the braces balance.
+- A call **within a line** of text renders inline and must stay on a single line; multi-line calls have to
+ start on their own line.
+- Template arguments are wikitext, not Markdown. Inside a GFM table cell, escape argument pipes as `\|`. Write
+ `\{\{` to keep braces literal.
+- A block call with no closing `}}` is rendered as literal text through to the end of the page (Markdown
+ parsing cannot backtrack), so a forgotten brace shows up as visible braces to fix rather than a silent error.
+
+Out of scope in this version, by design:
+
+- `[...]` and `` in the Markdown body (these are tags, not `{{...}}`), and citation
+ state is not shared across separate calls on a page.
+- Transcluding another **Markdown** page with `{{:Page}}`: its source would be reinterpreted as wikitext, so
+ transclude wikitext pages instead. `subst:` does not substitute on save.
+- Enabling the setting does not reparse existing pages; they show template output once edited or purged. Run
+ `refreshLinks.php` to populate the template links eagerly.
+
## For AI agents and LLMs
Markdown is the native read/write format of today's language models, and Native Markdown stores pages as
@@ -177,6 +219,7 @@ Initial release for MediaWiki 1.43+ with these features:
* Clean full-text search: rendered prose is indexed, not raw markup; front matter excluded
* YAML front matter parsed, hidden from output and stored as page metadata
* Per-page model switching via `Special:ChangeContentModel`, namespace/suffix/wiki-wide activation modes
+* Opt-in template transclusion: `{{template}}` calls expand via the MediaWiki parser, with dependency tracking
* XSS-safe by construction: raw HTML escaped, unsafe links blocked, external images off by default
* `action=raw` / REST return the stored Markdown byte for byte, built for AI agents and git round-trips
* CodeEditor syntax highlighting on Markdown pages
diff --git a/extension.json b/extension.json
index 7ad5e16..76b8996 100644
--- a/extension.json
+++ b/extension.json
@@ -56,6 +56,10 @@
"NativeMarkdownAllowExternalImages": {
"description": "If external images in markdown should be embedded. When false (default), they are rendered as plain links.",
"value": false
+ },
+ "NativeMarkdownTemplateTransclusion": {
+ "description": "If MediaWiki template calls ({{...}}) on markdown pages should be transcluded. When false (default), they are literal text. Enabling this also enables parser functions and magic words inside the braces, as expansion delegates to the MediaWiki parser.",
+ "value": false
}
},
diff --git a/src/Application/CommonMark/TemplateBraces.php b/src/Application/CommonMark/TemplateBraces.php
new file mode 100644
index 0000000..9672327
--- /dev/null
+++ b/src/Application/CommonMark/TemplateBraces.php
@@ -0,0 +1,57 @@
+[^{}\n]++|(?1))*\}\})/';
+
+ /**
+ * The balanced `{{...}}` call the text begins with, or null when it does not
+ * start with one (unbalanced, a `{{{` run, or a line break inside the braces).
+ */
+ public static function matchLeadingCall( string $text ): ?string {
+ if ( preg_match( self::LEADING_CALL, $text, $matches ) === 1 ) {
+ return $matches[1];
+ }
+
+ return null;
+ }
+
+ /**
+ * Net change in brace nesting across the text: `{{` opens a level, `}}` closes
+ * one. Stray single braces are ignored, which is enough to find where an outer
+ * call balances as its lines are accumulated.
+ */
+ public static function depthDelta( string $text ): int {
+ $delta = 0;
+ $length = strlen( $text );
+
+ for ( $i = 0; $i < $length - 1; ) {
+ $pair = $text[$i] . $text[$i + 1];
+
+ if ( $pair === '{{' ) {
+ $delta++;
+ $i += 2;
+ } elseif ( $pair === '}}' ) {
+ $delta--;
+ $i += 2;
+ } else {
+ $i++;
+ }
+ }
+
+ return $delta;
+ }
+
+}
diff --git a/src/Application/CommonMark/TemplateCallBlock.php b/src/Application/CommonMark/TemplateCallBlock.php
new file mode 100644
index 0000000..96273f2
--- /dev/null
+++ b/src/Application/CommonMark/TemplateCallBlock.php
@@ -0,0 +1,21 @@
+block = new TemplateCallBlock();
+ }
+
+ public function getBlock(): TemplateCallBlock {
+ return $this->block;
+ }
+
+ public function tryContinue( Cursor $cursor, BlockContinueParserInterface $activeBlockParser ): ?BlockContinue {
+ if ( $this->finished ) {
+ return BlockContinue::none();
+ }
+
+ return BlockContinue::at( $cursor );
+ }
+
+ public function addLine( string $line ): void {
+ $this->lines[] = $line;
+ $this->depth += TemplateBraces::depthDelta( $line );
+
+ if ( $this->depth <= 0 ) {
+ $this->finished = true;
+ }
+ }
+
+ public function closeBlock(): void {
+ $this->block->wikitext = trim( implode( "\n", $this->lines ) );
+ $this->block->balanced = $this->depth === 0;
+ }
+
+}
diff --git a/src/Application/CommonMark/TemplateCallBlockStartParser.php b/src/Application/CommonMark/TemplateCallBlockStartParser.php
new file mode 100644
index 0000000..80b9746
--- /dev/null
+++ b/src/Application/CommonMark/TemplateCallBlockStartParser.php
@@ -0,0 +1,50 @@
+isIndented() || $cursor->getNextNonSpaceCharacter() !== '{' ) {
+ return BlockStart::none();
+ }
+
+ $line = $cursor->getRemainder();
+
+ if ( preg_match( '/^[ \t]*\{\{(?!\{)/', $line ) !== 1 ) {
+ return BlockStart::none();
+ }
+
+ if ( $this->closesInlineOnFirstLine( $line ) ) {
+ return BlockStart::none();
+ }
+
+ return BlockStart::of( new TemplateCallBlockParser() )->at( $cursor );
+ }
+
+ /**
+ * True when the braces balance within this line and non-space text follows
+ * the closing `}}`, meaning the call belongs inline rather than as a block.
+ */
+ private function closesInlineOnFirstLine( string $line ): bool {
+ $fromBraces = ltrim( $line, " \t" );
+ $call = TemplateBraces::matchLeadingCall( $fromBraces );
+
+ return $call !== null && trim( substr( $fromBraces, strlen( $call ) ) ) !== '';
+ }
+
+}
diff --git a/src/Application/CommonMark/TemplateCallNode.php b/src/Application/CommonMark/TemplateCallNode.php
new file mode 100644
index 0000000..69f369e
--- /dev/null
+++ b/src/Application/CommonMark/TemplateCallNode.php
@@ -0,0 +1,24 @@
+getCursor();
+
+ // A preceding brace means we are inside a `{{{...}}}` run: leave it literal.
+ if ( $cursor->peek( -1 ) === '{' ) {
+ return false;
+ }
+
+ $wikitext = TemplateBraces::matchLeadingCall( $cursor->getRemainder() );
+
+ if ( $wikitext === null ) {
+ return false;
+ }
+
+ // advanceBy() counts characters, not bytes, so byte offsets must be
+ // converted, as in WikiLinkParser.
+ $cursor->advanceBy( mb_strlen( $wikitext, 'UTF-8' ) );
+ $inlineContext->getContainer()->appendChild( new TemplateCallNode( $wikitext ) );
+
+ return true;
+ }
+
+}
diff --git a/src/Application/CommonMark/TemplateCallRenderer.php b/src/Application/CommonMark/TemplateCallRenderer.php
new file mode 100644
index 0000000..af7b2af
--- /dev/null
+++ b/src/Application/CommonMark/TemplateCallRenderer.php
@@ -0,0 +1,32 @@
+expandedHtml ?? Xml::escape( $node->wikitext );
+ }
+
+ if ( $node instanceof TemplateCallBlock ) {
+ // The HTML renderer adds the trailing block separator itself.
+ return $node->expandedHtml ?? '
' . Xml::escape( $node->wikitext ) . '
';
+ }
+
+ return '';
+ }
+
+}
diff --git a/src/Application/MarkdownRenderer.php b/src/Application/MarkdownRenderer.php
index c30f9c7..0dde235 100644
--- a/src/Application/MarkdownRenderer.php
+++ b/src/Application/MarkdownRenderer.php
@@ -31,6 +31,11 @@
use ProfessionalWiki\NativeMarkdown\Application\CommonMark\FileEmbedNode;
use ProfessionalWiki\NativeMarkdown\Application\CommonMark\FileEmbedNodeRenderer;
use ProfessionalWiki\NativeMarkdown\Application\CommonMark\ImageLinkRenderer;
+use ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallBlock;
+use ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallBlockStartParser;
+use ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallNode;
+use ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallParser;
+use ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallRenderer;
use ProfessionalWiki\NativeMarkdown\Application\CommonMark\TocPlaceholder;
use ProfessionalWiki\NativeMarkdown\Application\CommonMark\TocPlaceholderRenderer;
use ProfessionalWiki\NativeMarkdown\Application\CommonMark\WikiLinkNode;
@@ -45,6 +50,7 @@
final class MarkdownRenderer {
private const WIKI_LINK_PARSER_PRIORITY = 100;
+ private const TEMPLATE_CALL_PARSER_PRIORITY = 100;
private const RENDERER_OVERRIDE_PRIORITY = 10;
private MarkdownParser $parser;
@@ -58,9 +64,15 @@ public function __construct(
bool $allowExternalImages,
int $maxNestingLevel,
private readonly ?string $tocPlaceholderHtml,
- bool $noFollowExternalLinks
+ bool $noFollowExternalLinks,
+ bool $templateTransclusion
) {
- $environment = $this->newEnvironment( $allowExternalImages, $maxNestingLevel, $noFollowExternalLinks );
+ $environment = $this->newEnvironment(
+ $allowExternalImages,
+ $maxNestingLevel,
+ $noFollowExternalLinks,
+ $templateTransclusion
+ );
$this->parser = new MarkdownParser( $environment );
$this->htmlRenderer = new HtmlRenderer( $environment );
@@ -70,7 +82,8 @@ public function __construct(
private function newEnvironment(
bool $allowExternalImages,
int $maxNestingLevel,
- bool $noFollowExternalLinks
+ bool $noFollowExternalLinks,
+ bool $templateTransclusion
): Environment {
$environment = new Environment( [
'html_input' => HtmlFilter::ESCAPE,
@@ -106,6 +119,17 @@ private function newEnvironment(
);
}
+ // Registered only when the feature is on, so with it off `{{...}}` is
+ // never recognized and output stays identical to plain markdown.
+ if ( $templateTransclusion ) {
+ $templateRenderer = new TemplateCallRenderer();
+
+ $environment->addInlineParser( new TemplateCallParser(), self::TEMPLATE_CALL_PARSER_PRIORITY );
+ $environment->addBlockStartParser( new TemplateCallBlockStartParser() );
+ $environment->addRenderer( TemplateCallNode::class, $templateRenderer );
+ $environment->addRenderer( TemplateCallBlock::class, $templateRenderer );
+ }
+
return $environment;
}
@@ -116,20 +140,36 @@ private function newEnvironment(
* link and file adapters) propagate — falling back on those would cache
* degraded output and empty the page's link tables.
*/
- public function render( string $markdown, bool $generateHtml ): RenderedMarkdown {
+ public function render(
+ string $markdown,
+ bool $generateHtml,
+ ?TemplateExpander $templateExpander = null
+ ): RenderedMarkdown {
try {
- return $this->renderDocument( $markdown, $generateHtml );
+ return $this->renderDocument( $markdown, $generateHtml, $templateExpander );
} catch ( CommonMarkException ) {
return $this->escapedSourceFallback( $markdown, $generateHtml );
}
}
- private function renderDocument( string $markdown, bool $generateHtml ): RenderedMarkdown {
+ private function renderDocument(
+ string $markdown,
+ bool $generateHtml,
+ ?TemplateExpander $templateExpander
+ ): RenderedMarkdown {
[ $frontMatter, $content ] = $this->extractFrontMatter( $markdown );
$document = $this->parser->parse( $content );
[ $links, $categories, $files ] = $this->resolveWikiLinks( $document );
+
+ // Runs regardless of $generateHtml: expansion records each template's
+ // wiki dependencies, and a metadata-only parse that skipped it would
+ // leave the link tables incomplete and break template-edit invalidation.
+ if ( $templateExpander !== null ) {
+ $this->expandTemplateCalls( $document, $templateExpander );
+ }
+
$this->preloadLinkExistence( $links );
$this->preloadFileLookups( $files, $generateHtml );
$sections = $this->processHeadings( $document );
@@ -145,6 +185,38 @@ private function renderDocument( string $markdown, bool $generateHtml ): Rendere
);
}
+ /**
+ * Replaces the raw wikitext of each template call with the expander's HTML.
+ * Unbalanced block calls are skipped and shown by the renderer as literal
+ * text rather than fed to the expander.
+ */
+ private function expandTemplateCalls( Document $document, TemplateExpander $templateExpander ): void {
+ foreach ( $this->templateCallNodes( $document ) as $node ) {
+ if ( $node instanceof TemplateCallBlock && !$node->balanced ) {
+ continue;
+ }
+
+ $node->expandedHtml = $templateExpander->expand(
+ new TemplateCall( $node->wikitext, $node instanceof TemplateCallBlock )
+ );
+ }
+ }
+
+ /**
+ * @return array
+ */
+ private function templateCallNodes( Document $document ): array {
+ $nodes = [];
+
+ foreach ( $document->iterator() as $node ) {
+ if ( $node instanceof TemplateCallNode || $node instanceof TemplateCallBlock ) {
+ $nodes[] = $node;
+ }
+ }
+
+ return $nodes;
+ }
+
/**
* The visible words of the document for the search index: markdown and wiki
* markup removed, front matter and category assignments excluded. No HTML is
@@ -197,6 +269,8 @@ private function nodeSearchText( Node $node ): string {
return match ( true ) {
$node instanceof WikiLinkNode => ' ' . $node->displayLabel() . ' ',
$node instanceof FileEmbedNode => ' ' . ( $node->embed->caption ?? $node->embed->altText ?? '' ) . ' ',
+ $node instanceof TemplateCallNode => '',
+ $node instanceof TemplateCallBlock => '',
$node instanceof StringContainerInterface => $node->getLiteral(),
$node instanceof Newline => ' ',
default => ''
@@ -483,6 +557,7 @@ private function plainText( Node $node ): string {
$text .= match ( true ) {
$child instanceof WikiLinkNode => $child->displayLabel(),
$child instanceof FileEmbedNode => '',
+ $child instanceof TemplateCallNode => '',
$child instanceof RawMarkupContainerInterface => '',
$child instanceof StringContainerInterface => $child->getLiteral(),
$child instanceof Newline => ' ',
diff --git a/src/Application/TemplateCall.php b/src/Application/TemplateCall.php
new file mode 100644
index 0000000..5a26184
--- /dev/null
+++ b/src/Application/TemplateCall.php
@@ -0,0 +1,20 @@
+getMarkdownRenderer()->render(
+ $extension = NativeMarkdownExtension::getInstance();
+
+ $templateExpander = $extension->newTemplateExpander(
+ $cpoParams->getPage(),
+ $cpoParams->getParserOptions(),
+ $cpoParams->getRevId()
+ );
+
+ $rendered = $extension->getMarkdownRenderer()->render(
$text,
- $cpoParams->getGenerateHtml()
+ $cpoParams->getGenerateHtml(),
+ $templateExpander
);
$output->setFromParserOptions( $cpoParams->getParserOptions() );
@@ -132,6 +141,13 @@ protected function fillParserOutput( Content $content, ContentParseParams $cpoPa
$this->registerFiles( $output, $rendered );
$this->registerExternalLinks( $output, $rendered );
$this->registerFrontMatter( $output, $rendered );
+
+ if ( $templateExpander !== null ) {
+ $templateExpander->mergeInto( $output );
+ }
+
+ // Last, so the page's table of contents comes from its own markdown
+ // headings, not from headings inside transcluded templates.
$this->registerSections( $output, $rendered );
$output->setRawText( $cpoParams->getGenerateHtml() ? $rendered->html : null );
@@ -192,16 +208,17 @@ private function registerFrontMatter( ParserOutput $output, RenderedMarkdown $re
}
}
+ /**
+ * Sets the table of contents from the page's own markdown headings,
+ * authoritatively: it overwrites any TOCData and SHOW_TOC flag a transcluded
+ * template merged in, so the contents list never lists template headings.
+ */
private function registerSections( ParserOutput $output, RenderedMarkdown $rendered ): void {
- if ( $rendered->sections === [] ) {
- return;
- }
-
$output->setTOCData( $this->buildTocData( $rendered->sections ) );
-
- if ( count( $rendered->sections ) >= self::TOC_SECTION_THRESHOLD ) {
- $output->setOutputFlag( ParserOutputFlags::SHOW_TOC );
- }
+ $output->setOutputFlag(
+ ParserOutputFlags::SHOW_TOC,
+ count( $rendered->sections ) >= self::TOC_SECTION_THRESHOLD
+ );
}
/**
diff --git a/src/NativeMarkdownExtension.php b/src/NativeMarkdownExtension.php
index 430a8f4..5e63fcd 100644
--- a/src/NativeMarkdownExtension.php
+++ b/src/NativeMarkdownExtension.php
@@ -5,12 +5,15 @@
namespace ProfessionalWiki\NativeMarkdown;
use MediaWiki\MediaWikiServices;
+use MediaWiki\Page\PageReference;
use MediaWiki\Parser\Parser;
+use MediaWiki\Parser\ParserOptions;
use ProfessionalWiki\NativeMarkdown\Application\MarkdownDefaultPolicy;
use ProfessionalWiki\NativeMarkdown\Application\MarkdownRenderer;
use ProfessionalWiki\NativeMarkdown\Application\RedirectSyntax;
use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiFileEmbedRenderer;
use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiPageLinkRenderer;
+use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiTemplateExpander;
use ProfessionalWiki\NativeMarkdown\Persistence\MediaWikiTitleParser;
/**
@@ -66,7 +69,8 @@ private function newMarkdownRenderer(): MarkdownRenderer {
allowExternalImages: (bool)$services->getMainConfig()->get( 'NativeMarkdownAllowExternalImages' ),
maxNestingLevel: self::MAX_NESTING_LEVEL,
tocPlaceholderHtml: Parser::TOC_PLACEHOLDER,
- noFollowExternalLinks: (bool)$services->getMainConfig()->get( 'NoFollowLinks' )
+ noFollowExternalLinks: (bool)$services->getMainConfig()->get( 'NoFollowLinks' ),
+ templateTransclusion: (bool)$services->getMainConfig()->get( 'NativeMarkdownTemplateTransclusion' )
);
}
@@ -82,6 +86,30 @@ private function defaultThumbnailWidth( MediaWikiServices $services ): int {
return (int)( $thumbLimits[$defaultThumbSize] ?? 300 );
}
+ /**
+ * The expander a single page render uses to transclude its template calls,
+ * or null when transclusion is disabled. Built per render because it carries
+ * that render's page, options and revision.
+ */
+ public function newTemplateExpander(
+ PageReference $page,
+ ParserOptions $parserOptions,
+ ?int $revisionId
+ ): ?MediaWikiTemplateExpander {
+ $services = MediaWikiServices::getInstance();
+
+ if ( !$services->getMainConfig()->get( 'NativeMarkdownTemplateTransclusion' ) ) {
+ return null;
+ }
+
+ return new MediaWikiTemplateExpander(
+ parserFactory: $services->getParserFactory(),
+ page: $page,
+ parserOptions: $parserOptions,
+ revisionId: $revisionId
+ );
+ }
+
public function newRedirectSyntax(): RedirectSyntax {
return new RedirectSyntax(
magicWordSynonyms: MediaWikiServices::getInstance()
diff --git a/src/Persistence/MediaWikiTemplateExpander.php b/src/Persistence/MediaWikiTemplateExpander.php
new file mode 100644
index 0000000..49276eb
--- /dev/null
+++ b/src/Persistence/MediaWikiTemplateExpander.php
@@ -0,0 +1,97 @@
+childOptions = clone $parserOptions;
+ }
+
+ public function expand( TemplateCall $call ): string {
+ $output = $this->parser()->parse(
+ $call->wikitext,
+ $this->page,
+ $this->childOptions,
+ true,
+ true,
+ $this->revisionId
+ );
+
+ $this->childOutputs[] = $output;
+
+ $html = $output->runOutputPipeline(
+ $this->childOptions,
+ [
+ // The page composes its own wrapper, ToC and section edit links;
+ // keeping the fragment free of them avoids per-user, per-skin
+ // artifacts being frozen into the page's parser cache.
+ 'unwrap' => true,
+ 'allowTOC' => false,
+ 'enableSectionEditLinks' => false,
+ ]
+ )->getContentHolderText();
+
+ // A block call keeps the parser's block HTML; an inline call drops the
+ // paragraph the parser wraps loose text in so it flows within the line.
+ return $call->isBlock ? $html : Parser::stripOuterParagraph( $html );
+ }
+
+ /**
+ * Folds every child parse's metadata into the page's output using the same
+ * merges core uses to combine content slots (RevisionRenderer), plus the
+ * cache expiry no merge method carries, so templatelinks/imagelinks/
+ * categorylinks, the modules templates need, cache-varying options and TTLs
+ * are all recorded against the page.
+ */
+ public function mergeInto( ParserOutput $pageOutput ): void {
+ foreach ( $this->childOutputs as $childOutput ) {
+ $pageOutput->mergeInternalMetaDataFrom( $childOutput );
+ $pageOutput->mergeTrackingMetaDataFrom( $childOutput );
+ $pageOutput->mergeHtmlMetaDataFrom( $childOutput );
+ $pageOutput->updateCacheExpiry( $childOutput->getCacheExpiry() );
+ }
+ }
+
+ private function parser(): Parser {
+ $this->parser ??= $this->parserFactory->create();
+
+ return $this->parser;
+ }
+
+}
diff --git a/tests/phpunit/Application/FixtureRenderingTest.php b/tests/phpunit/Application/FixtureRenderingTest.php
index 4380965..639433f 100644
--- a/tests/phpunit/Application/FixtureRenderingTest.php
+++ b/tests/phpunit/Application/FixtureRenderingTest.php
@@ -48,7 +48,8 @@ private function render( string $markdown ): string {
allowExternalImages: false,
maxNestingLevel: 100,
tocPlaceholderHtml: null,
- noFollowExternalLinks: true
+ noFollowExternalLinks: true,
+ templateTransclusion: false
);
return $renderer->render( $markdown, generateHtml: true )->html;
diff --git a/tests/phpunit/Application/MarkdownRendererTest.php b/tests/phpunit/Application/MarkdownRendererTest.php
index d5b4b6c..bf17ac7 100644
--- a/tests/phpunit/Application/MarkdownRendererTest.php
+++ b/tests/phpunit/Application/MarkdownRendererTest.php
@@ -9,8 +9,10 @@
use ProfessionalWiki\NativeMarkdown\Application\PageLinkRenderer;
use ProfessionalWiki\NativeMarkdown\Application\RenderedMarkdown;
use ProfessionalWiki\NativeMarkdown\Application\Section;
+use ProfessionalWiki\NativeMarkdown\Application\TemplateExpander;
use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeFileEmbedRenderer;
use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakePageLinkRenderer;
+use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeTemplateExpander;
use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\FakeWikiTitleParser;
use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\RecordingPageLinkRenderer;
use ProfessionalWiki\NativeMarkdown\Tests\TestDoubles\SpyPageLinkRenderer;
@@ -28,13 +30,22 @@
* @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\ExternalLinkRenderer
* @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TocPlaceholder
* @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TocPlaceholderRenderer
+ * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallParser
+ * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallBlockStartParser
+ * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallBlockParser
+ * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallNode
+ * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallBlock
+ * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateCallRenderer
+ * @covers \ProfessionalWiki\NativeMarkdown\Application\CommonMark\TemplateBraces
+ * @covers \ProfessionalWiki\NativeMarkdown\Application\TemplateCall
*/
class MarkdownRendererTest extends TestCase {
private function newRenderer(
bool $allowExternalImages = false,
?PageLinkRenderer $pageLinkRenderer = null,
- ?string $tocPlaceholderHtml = null
+ ?string $tocPlaceholderHtml = null,
+ bool $templateTransclusion = false
): MarkdownRenderer {
return new MarkdownRenderer(
titleParser: new FakeWikiTitleParser(),
@@ -43,7 +54,8 @@ private function newRenderer(
allowExternalImages: $allowExternalImages,
maxNestingLevel: 100,
tocPlaceholderHtml: $tocPlaceholderHtml,
- noFollowExternalLinks: true
+ noFollowExternalLinks: true,
+ templateTransclusion: $templateTransclusion
);
}
@@ -52,10 +64,12 @@ private function render(
bool $allowExternalImages = false,
?PageLinkRenderer $pageLinkRenderer = null,
bool $generateHtml = true,
- ?string $tocPlaceholderHtml = null
+ ?string $tocPlaceholderHtml = null,
+ bool $templateTransclusion = false,
+ ?TemplateExpander $templateExpander = null
): RenderedMarkdown {
- return $this->newRenderer( $allowExternalImages, $pageLinkRenderer, $tocPlaceholderHtml )
- ->render( $markdown, $generateHtml );
+ return $this->newRenderer( $allowExternalImages, $pageLinkRenderer, $tocPlaceholderHtml, $templateTransclusion )
+ ->render( $markdown, $generateHtml, $templateExpander );
}
private function extractPlainText( string $markdown ): string {
@@ -438,7 +452,8 @@ public function testFileLookupsArePreloadedInOneBatchBeforeRendering(): void {
allowExternalImages: false,
maxNestingLevel: 100,
tocPlaceholderHtml: null,
- noFollowExternalLinks: true
+ noFollowExternalLinks: true,
+ templateTransclusion: false
);
$renderer->render( "[[File:First.png]] and [[File:Second.png]]", true );
@@ -821,4 +836,207 @@ public function testMetadataOnlyRenderSkipsHtmlAndLinkRendering(): void {
$this->assertSame( 0, $spy->renderedLinkCount );
}
+ public function testTemplateCallsAreLiteralTextWhenTransclusionDisabled(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render( 'See {{Greeting}} here', templateExpander: $expander );
+
+ $this->assertSame( "See {{Greeting}} here
\n", $result->html );
+ $this->assertSame( [], $expander->calls );
+ }
+
+ public function testInlineTemplateCallIsExpandedAndInjectedRaw(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render(
+ 'See {{Greeting}} here',
+ templateTransclusion: true,
+ templateExpander: $expander
+ );
+
+ $this->assertSame(
+ "See {{Greeting}} here
\n",
+ $result->html
+ );
+ }
+
+ public function testInlineTemplateCallPassesWikitextAndInlineFlagToExpander(): void {
+ $expander = new FakeTemplateExpander();
+
+ $this->render( 'See {{Greeting|Ada}} here', templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertCount( 1, $expander->calls );
+ $this->assertSame( '{{Greeting|Ada}}', $expander->calls[0]->wikitext );
+ $this->assertFalse( $expander->calls[0]->isBlock );
+ }
+
+ public function testBlockTemplateCallOnItsOwnLineIsNotWrappedInParagraph(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render( '{{Infobox}}', templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertStringContainsString( '{{Infobox}}
', $result->html );
+ $this->assertStringNotContainsString( '', $result->html );
+ $this->assertTrue( $expander->calls[0]->isBlock );
+ }
+
+ public function testMultiLineBlockTemplateIsCapturedAsOneCall(): void {
+ $expander = new FakeTemplateExpander();
+ $wikitext = "{{Infobox person\n| name = Ada\n| born = 1815\n}}";
+
+ $this->render( $wikitext, templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertCount( 1, $expander->calls );
+ $this->assertSame( $wikitext, $expander->calls[0]->wikitext );
+ $this->assertTrue( $expander->calls[0]->isBlock );
+ }
+
+ public function testMultiLineBlockTemplateKeepsBlankParameterLines(): void {
+ $expander = new FakeTemplateExpander();
+ $wikitext = "{{Infobox\n| a = 1\n\n| b = 2\n}}";
+
+ $this->render( $wikitext, templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertSame( $wikitext, $expander->calls[0]->wikitext );
+ }
+
+ public function testNestedTemplateBecomesOneCall(): void {
+ $expander = new FakeTemplateExpander();
+
+ $this->render( 'X {{outer|{{inner}}}} Y', templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertCount( 1, $expander->calls );
+ $this->assertSame( '{{outer|{{inner}}}}', $expander->calls[0]->wikitext );
+ }
+
+ public function testTripleBraceArgumentSyntaxStaysLiteral(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render( 'Value: {{{param}}} here', templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertSame( [], $expander->calls );
+ $this->assertStringContainsString( '{{{param}}}', $result->html );
+ }
+
+ public function testTemplateCallWithoutExpanderDegradesToEscapedLiteral(): void {
+ $result = $this->render( '{{Infobox}}', templateTransclusion: true );
+
+ $this->assertSame( "
{{Infobox}}
\n", $result->html );
+ }
+
+ public function testBalancedCallWithTrailingTextIsInlineNotBlock(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render( '{{Foo}} and more', templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertFalse( $expander->calls[0]->isBlock );
+ $this->assertSame(
+ "{{Foo}} and more
\n",
+ $result->html
+ );
+ }
+
+ public function testUnclosedBlockTemplateDegradesToLiteralInsteadOfExpanding(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render(
+ "{{Unclosed\nmore body text\nand still more",
+ templateTransclusion: true,
+ templateExpander: $expander
+ );
+
+ $this->assertSame( [], $expander->calls );
+ $this->assertStringContainsString( '{{Unclosed', $result->html );
+ }
+
+ public function testTemplateCallInsideInlineCodeStaysLiteral(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render( 'Use `{{Foo}}` in code', templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertSame( [], $expander->calls );
+ $this->assertStringContainsString( '{{Foo}}', $result->html );
+ }
+
+ public function testTemplateCallInsideFencedCodeStaysLiteral(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render(
+ "```\n{{Foo}}\n```",
+ templateTransclusion: true,
+ templateExpander: $expander
+ );
+
+ $this->assertSame( [], $expander->calls );
+ $this->assertStringContainsString( '{{Foo}}', $result->html );
+ }
+
+ public function testBackslashEscapedBracesStayLiteral(): void {
+ $expander = new FakeTemplateExpander();
+
+ $result = $this->render( 'Literal \\{\\{Foo}} here', templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertSame( [], $expander->calls );
+ $this->assertStringContainsString( '{{Foo}}', $result->html );
+ }
+
+ public function testTemplateCallInHeadingContributesNothingToAnchor(): void {
+ $result = $this->render(
+ '## Section {{Foo}}',
+ templateTransclusion: true,
+ templateExpander: new FakeTemplateExpander()
+ );
+
+ $this->assertStringNotContainsString( 'Foo', $result->sections[0]->anchor );
+ $this->assertStringNotContainsString( '{', $result->sections[0]->anchor );
+ }
+
+ public function testSearchTextExcludesTemplateWikitext(): void {
+ $text = $this->newRenderer( templateTransclusion: true )
+ ->extractPlainText( 'Body {{Foo|bar}} words' );
+
+ $this->assertStringNotContainsString( 'Foo', $text );
+ $this->assertStringNotContainsString( '{{', $text );
+ $this->assertStringContainsString( 'Body', $text );
+ $this->assertStringContainsString( 'words', $text );
+ }
+
+ public function testTemplateExpansionRunsEvenWhenNotGeneratingHtml(): void {
+ $expander = new FakeTemplateExpander();
+
+ $this->render(
+ '{{Infobox}}',
+ generateHtml: false,
+ templateTransclusion: true,
+ templateExpander: $expander
+ );
+
+ $this->assertCount( 1, $expander->calls );
+ }
+
+ public function testEachTemplateCallOnItsOwnLineIsExpanded(): void {
+ $expander = new FakeTemplateExpander();
+
+ $this->render( "{{First}}\n\n{{Second}}", templateTransclusion: true, templateExpander: $expander );
+
+ $this->assertSame(
+ [ '{{First}}', '{{Second}}' ],
+ array_map( static fn ( $call ) => $call->wikitext, $expander->calls )
+ );
+ }
+
+ public function testTemplateCallInTableCellUsesEscapedPipeForArguments(): void {
+ $expander = new FakeTemplateExpander();
+
+ $this->render(
+ "| Col |\n|---|\n| {{Greeting\\|Ada}} |",
+ templateTransclusion: true,
+ templateExpander: $expander
+ );
+
+ $this->assertCount( 1, $expander->calls );
+ $this->assertSame( '{{Greeting|Ada}}', $expander->calls[0]->wikitext );
+ $this->assertFalse( $expander->calls[0]->isBlock );
+ }
+
}
diff --git a/tests/phpunit/Application/XssSafetyTest.php b/tests/phpunit/Application/XssSafetyTest.php
index 0c2290d..bea904a 100644
--- a/tests/phpunit/Application/XssSafetyTest.php
+++ b/tests/phpunit/Application/XssSafetyTest.php
@@ -94,7 +94,8 @@ private function render( string $markdown, bool $allowExternalImages = false ):
allowExternalImages: $allowExternalImages,
maxNestingLevel: 100,
tocPlaceholderHtml: null,
- noFollowExternalLinks: true
+ noFollowExternalLinks: true,
+ templateTransclusion: false
);
return $renderer->render( $markdown, generateHtml: true )->html;
diff --git a/tests/phpunit/EntryPoints/MarkdownTransclusionTest.php b/tests/phpunit/EntryPoints/MarkdownTransclusionTest.php
new file mode 100644
index 0000000..bff31d2
--- /dev/null
+++ b/tests/phpunit/EntryPoints/MarkdownTransclusionTest.php
@@ -0,0 +1,126 @@
+overrideConfigValue( 'NativeMarkdownTemplateTransclusion', true );
+ }
+
+ private function getParserOutput( string $markdown, bool $generateHtml = true ): ParserOutput {
+ return $this->getServiceContainer()->getContentRenderer()->getParserOutput(
+ new MarkdownContent( $markdown ),
+ PageReferenceValue::localReference( NS_MAIN, self::PAGE ),
+ null,
+ null,
+ [ 'generate-html' => $generateHtml ]
+ );
+ }
+
+ public function testTranscludedTemplateRendersInPageHtml(): void {
+ $this->editPage( 'Template:Greeting', 'Hello from the template' );
+
+ $output = $this->getParserOutput( 'Intro {{Greeting}} outro' );
+
+ $this->assertStringContainsString( 'Hello from the template', $output->getRawText() );
+ }
+
+ public function testBlockTemplateRendersTableFromWikitext(): void {
+ $this->editPage( 'Template:Infobox', "{| class=\"wikitable\"\n| Cell content\n|}" );
+
+ $output = $this->getParserOutput( '{{Infobox}}' );
+
+ $this->assertStringContainsString( 'getRawText() );
+ $this->assertStringContainsString( 'Cell content', $output->getRawText() );
+ }
+
+ public function testTransclusionRecordsTemplateDependency(): void {
+ $this->editPage( 'Template:Greeting', 'Hello' );
+
+ $output = $this->getParserOutput( '{{Greeting}}' );
+
+ $this->assertArrayHasKey( 'Greeting', $output->getTemplates()[NS_TEMPLATE] ?? [] );
+ }
+
+ public function testTemplateDependencyIsRecordedEvenWithoutHtml(): void {
+ $this->editPage( 'Template:Greeting', 'Hello' );
+
+ $output = $this->getParserOutput( '{{Greeting}}', generateHtml: false );
+
+ $this->assertArrayHasKey( 'Greeting', $output->getTemplates()[NS_TEMPLATE] ?? [] );
+ }
+
+ public function testCategoryAddedByTemplateIsRegisteredOnThePage(): void {
+ $this->editPage( 'Template:Categoriser', '[[Category:From Template]]' );
+
+ $output = $this->getParserOutput( 'Body {{Categoriser}}' );
+
+ $this->assertContains( 'From_Template', $output->getCategoryNames() );
+ }
+
+ public function testTemplateArgumentHtmlIsSanitized(): void {
+ $this->editPage( 'Template:Echo', '{{{1}}}' );
+
+ $output = $this->getParserOutput( '{{Echo|}}' );
+
+ $this->assertStringNotContainsString( '