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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 `![alt](url)` 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
Expand All @@ -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:

- `<ref>...</ref>` and `<references/>` 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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions extension.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},

Expand Down
57 changes: 57 additions & 0 deletions src/Application/CommonMark/TemplateBraces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application\CommonMark;

/**
* Matching and measuring `{{...}}` brace spans, shared by the inline and block
* template-call parsers so they agree on what a balanced call is.
*/
final class TemplateBraces {

// Balanced `{{...}}` at the start of the string, allowing nested calls on a
// single line. The recursion refers to group 1 (not the whole pattern) so the
// `\A` anchor stays outside it; the atomic group bounds backtracking so
// adversarial brace runs fail fast to no match.
private const LEADING_CALL = '/\A(\{\{(?>[^{}\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;
}

}
21 changes: 21 additions & 0 deletions src/Application/CommonMark/TemplateCallBlock.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application\CommonMark;

use League\CommonMark\Node\Block\AbstractBlock;

/**
* AST node for a block-level `{{...}}` call standing on its own line(s), such
* as an infobox. The continue parser fills in the raw wikitext and whether the
* braces balanced; an unbalanced call (never closed before end of input)
* degrades to escaped literal text instead of being expanded.
*/
final class TemplateCallBlock extends AbstractBlock {

public string $wikitext = '';
public bool $balanced = false;
public ?string $expandedHtml = null;

}
57 changes: 57 additions & 0 deletions src/Application/CommonMark/TemplateCallBlockParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application\CommonMark;

use League\CommonMark\Parser\Block\AbstractBlockContinueParser;
use League\CommonMark\Parser\Block\BlockContinue;
use League\CommonMark\Parser\Block\BlockContinueParserInterface;
use League\CommonMark\Parser\Cursor;

/**
* Accumulates the lines of a block-level `{{...}}` call until the braces
* balance, then stores the raw wikitext on the TemplateCallBlock. A call that
* never closes before the end of input is marked unbalanced and later degrades
* to escaped literal text rather than being expanded.
*/
final class TemplateCallBlockParser extends AbstractBlockContinueParser {

private TemplateCallBlock $block;

/** @var string[] */
private array $lines = [];
private int $depth = 0;
private bool $finished = false;

public function __construct() {
$this->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;
}

}
50 changes: 50 additions & 0 deletions src/Application/CommonMark/TemplateCallBlockStartParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application\CommonMark;

use League\CommonMark\Parser\Block\BlockStart;
use League\CommonMark\Parser\Block\BlockStartParserInterface;
use League\CommonMark\Parser\Cursor;
use League\CommonMark\Parser\MarkdownParserStateInterface;

/**
* Starts a block-level template call when a line begins with `{{` (an infobox
* or navbox on its own line). A call that closes on the same line with trailing
* text is left for the inline parser; `{{{` argument syntax is left literal.
* The call may span multiple lines, including blank ones, until the braces
* balance; TemplateCallBlockParser accumulates those lines.
*/
final class TemplateCallBlockStartParser implements BlockStartParserInterface {

public function tryStart( Cursor $cursor, MarkdownParserStateInterface $parserState ): ?BlockStart {
if ( $cursor->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 ) ) ) !== '';
}

}
24 changes: 24 additions & 0 deletions src/Application/CommonMark/TemplateCallNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application\CommonMark;

use League\CommonMark\Node\Inline\AbstractInline;

/**
* AST node for an inline `{{...}}` call. Holds the raw wikitext (braces
* included); the expanded HTML is filled in by a later pipeline stage and
* stays null when no expander runs, degrading to escaped literal text.
*/
final class TemplateCallNode extends AbstractInline {

public ?string $expandedHtml = null;

public function __construct(
public readonly string $wikitext
) {
parent::__construct();
}

}
46 changes: 46 additions & 0 deletions src/Application/CommonMark/TemplateCallParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application\CommonMark;

use League\CommonMark\Parser\Inline\InlineParserInterface;
use League\CommonMark\Parser\Inline\InlineParserMatch;
use League\CommonMark\Parser\InlineParserContext;

/**
* Inline parser for `{{...}}` template calls that sit within a line of text.
* Captures the balanced brace span, handing nested calls and arguments to the
* expander untouched. Own-line block calls are handled by
* TemplateCallBlockStartParser; multi-line spans and `{{{...}}}` argument
* syntax fall through to literal text.
*/
final class TemplateCallParser implements InlineParserInterface {

public function getMatchDefinition(): InlineParserMatch {
return InlineParserMatch::string( '{{' );
}

public function parse( InlineParserContext $inlineContext ): bool {
$cursor = $inlineContext->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;
}

}
32 changes: 32 additions & 0 deletions src/Application/CommonMark/TemplateCallRenderer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php

declare( strict_types = 1 );

namespace ProfessionalWiki\NativeMarkdown\Application\CommonMark;

use League\CommonMark\Node\Node;
use League\CommonMark\Renderer\ChildNodeRendererInterface;
use League\CommonMark\Renderer\NodeRendererInterface;
use League\CommonMark\Util\Xml;

/**
* Renders template-call nodes as the expander's trusted HTML. When no expansion
* ran (feature off, no page context, or an unbalanced block) the raw wikitext
* is shown escaped, mirroring how unresolved wikilinks degrade to literal text.
*/
final class TemplateCallRenderer implements NodeRendererInterface {

public function render( Node $node, ChildNodeRendererInterface $childRenderer ): string {
if ( $node instanceof TemplateCallNode ) {
return $node->expandedHtml ?? Xml::escape( $node->wikitext );
}

if ( $node instanceof TemplateCallBlock ) {
// The HTML renderer adds the trailing block separator itself.
return $node->expandedHtml ?? '<p>' . Xml::escape( $node->wikitext ) . '</p>';
}

return '';
}

}
Loading
Loading