Skip to content

Custom Syntax Patterns

Extend Djot with custom inline and block syntax patterns.

Table of Contents

Working with the AST

For complex transformations, work directly with the AST:

php
use Djot\Parser\BlockParser;
use Djot\Renderer\HtmlRenderer;
use Djot\Node\Block\Heading;
use Djot\Node\Inline\Text;

$parser = new BlockParser();
$renderer = new HtmlRenderer();

$djot = <<<'DJOT'
# Hello World

This is a paragraph.

## Section One

Content here.
DJOT;

// Parse to AST
$document = $parser->parse($djot);

// Traverse and modify
foreach ($document->getChildren() as $node) {
    if ($node instanceof Heading) {
        // Add prefix to all headings
        $children = $node->getChildren();
        if (!empty($children) && $children[0] instanceof Text) {
            $text = $children[0];
            $text->setContent('📌 ' . $text->getContent());
        }

        // Add class to all headings
        $node->addClass('section-heading');
    }
}

// Render modified AST
echo $renderer->render($document);

Output:

html
<h1 class="section-heading">📌 Hello World</h1>
<p>This is a paragraph.</p>
<h2 class="section-heading">📌 Section One</h2>
<p>Content here.</p>

Combining Multiple Customizations

Chain multiple event handlers for comprehensive customization:

php
use Djot\DjotConverter;
use Djot\Event\RenderEvent;

$converter = new DjotConverter();

// External links
$converter->on('render.link', function (RenderEvent $event): void {
    $link = $event->getNode();
    $href = $link->getDestination();
    if (str_starts_with($href, 'http')) {
        $link->setAttribute('target', '_blank');
        $link->setAttribute('rel', 'noopener');
    }
});

// Lazy images
$converter->on('render.image', function (RenderEvent $event): void {
    $event->getNode()->setAttribute('loading', 'lazy');
});

// Custom symbols
$converter->on('render.symbol', function (RenderEvent $event): void {
    $name = $event->getNode()->getName();
    $emoji = match ($name) {
        'check' => '✓',
        'x' => '✗',
        default => ':' . $name . ':',
    };
    $event->setHtml($emoji);
});

// Heading IDs
$converter->on('render.heading', function (RenderEvent $event): void {
    $heading = $event->getNode();
    $text = '';
    foreach ($heading->getChildren() as $child) {
        if ($child instanceof \Djot\Node\ContentNodeInterface) {
            $text .= $child->getContent();
        }
    }
    $slug = strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($text)));
    $heading->setAttribute('id', $slug);
});

// Now convert
$html = $converter->convert($djotContent);

Custom Inline Patterns

Extend Djot with custom inline syntax by registering patterns on the InlineParser.

@Mentions

For standard @mention handling, use MentionsExtension.

For custom mention logic (user lookup, validation, etc.), use inline patterns:

php
use Djot\Node\Inline\Link;
use Djot\Node\Inline\Text;

$parser = $converter->getParser()->getInlineParser();

$parser->addInlinePattern('/@([a-zA-Z0-9_]+)/', function ($match, $groups) {
    $username = $groups[1];
    // Custom logic: lookup user, validate, etc.
    $link = new Link('/profile/' . strtolower($username));
    $link->appendChild(new Text('@' . $username));
    return $link;
});

Support wiki-style links using a wiki: URL scheme. This approach uses standard djot link syntax and avoids ambiguity with nested spans (since [[x]{.a}]{.b} is valid djot).

php
use Djot\DjotConverter;
use Djot\Event\RenderEvent;

$converter = new DjotConverter();

// Handle wiki: scheme in links
$converter->on('render.link', function (RenderEvent $event): void {
    $link = $event->getNode();
    $url = $link->getDestination() ?? '';

    if (str_starts_with($url, 'wiki:')) {
        $target = substr($url, 5); // Remove 'wiki:' prefix

        // If empty, use the link text as target
        if ($target === '') {
            $text = '';
            foreach ($link->getChildren() as $child) {
                if ($child instanceof \Djot\Node\Inline\Text) {
                    $text .= $child->getContent();
                }
            }
            $target = $text;
        }

        // Convert to URL slug
        $slug = strtolower(str_replace(' ', '-', $target));
        $link->setDestination('/wiki/' . rawurlencode($slug));
        $link->setAttribute('class', 'wikilink');
    }
});

echo $converter->convert('See [Home Page](wiki:) and [the API docs](wiki:API Reference).');

Output:

html
<p>See <a href="/wiki/home-page" class="wikilink">Home Page</a> and <a href="/wiki/api-reference" class="wikilink">the API docs</a>.</p>

The syntax:

  • [Page Name](wiki:) - link text becomes the target
  • [display text](wiki:Page Name) - explicit target with custom display text

Configurable Base URL

php
$wikiBaseUrl = '/docs/';  // or 'https://wiki.example.com/'

$converter->on('render.link', function (RenderEvent $event) use ($wikiBaseUrl): void {
    $link = $event->getNode();
    $url = $link->getDestination() ?? '';

    if (str_starts_with($url, 'wiki:')) {
        $target = substr($url, 5);

        if ($target === '') {
            $text = '';
            foreach ($link->getChildren() as $child) {
                if ($child instanceof \Djot\Node\Inline\Text) {
                    $text .= $child->getContent();
                }
            }
            $target = $text;
        }

        $slug = strtolower(str_replace(' ', '-', $target));
        $link->setDestination($wikiBaseUrl . rawurlencode($slug));
    }
});

With File Extension

php
use Djot\Event\RenderEvent;

// Add .html extension for static sites
$converter->on('render.link', function (RenderEvent $event): void {
    $link = $event->getNode();
    $url = $link->getDestination() ?? '';

    if (str_starts_with($url, 'wiki:')) {
        $target = substr($url, 5);

        if ($target === '') {
            $text = '';
            foreach ($link->getChildren() as $child) {
                if ($child instanceof \Djot\Node\Inline\Text) {
                    $text .= $child->getContent();
                }
            }
            $target = $text;
        }

        $slug = strtolower(str_replace(' ', '-', $target));
        $link->setDestination('/pages/' . $slug . '.html');
    }
});

// [Installation Guide](wiki:) → <a href="/pages/installation-guide.html">Installation Guide</a>

Hashtags

Convert #hashtag to tag links:

php
$parser->addInlinePattern('/#([a-zA-Z][a-zA-Z0-9_]*)/', function ($match, $groups, $p) {
    $tag = $groups[1];
    $link = new Link('/tags/' . strtolower($tag));
    $link->appendChild(new Text('#' . $tag));
    $link->setAttribute('class', 'hashtag');
    return $link;
});

echo $converter->convert('Check out #PHP and #WebDev!');

Support <~/path> and <~/path|display text> for site-root-relative links:

php
use Djot\DjotConverter;
use Djot\Node\Inline\Link;
use Djot\Node\Inline\Text;

$converter = new DjotConverter();
$parser = $converter->getParser()->getInlineParser();

// Pattern matches <~/path> or <~/path|display text>
$parser->addInlinePattern('/<~([^>|]+)(?:\|([^>]+))?>/', function ($match, $groups, $p) {
    $path = trim($groups[1]);
    $display = isset($groups[2]) ? trim($groups[2]) : basename($path);

    // Build root-relative URL
    $url = '/' . ltrim($path, '/');

    $link = new Link($url);
    $link->appendChild(new Text($display));
    $link->setAttribute('class', 'internal-link');
    return $link;
});

echo $converter->convert('See <~/docs/installation> and <~/api/users|the API>.');

Output:

html
<p>See <a href="/docs/installation" class="internal-link">installation</a> and <a href="/api/users" class="internal-link">the API</a>.</p>

Configurable Base Path

php
$basePath = '/docs/v2';  // Prepend to all paths

$parser->addInlinePattern('/<~([^>|]+)(?:\|([^>]+))?>/', function ($match, $groups, $p) use ($basePath) {
    $path = trim($groups[1]);
    $display = isset($groups[2]) ? trim($groups[2]) : basename($path);

    $url = $basePath . '/' . ltrim($path, '/');

    $link = new Link($url);
    $link->appendChild(new Text($display));
    return $link;
});

// <~/guide> → <a href="/docs/v2/guide">guide</a>

Conditional Patterns

Return null to fall back to default parsing:

php
$parser->addInlinePattern('/@([a-zA-Z0-9_]+)/', function ($match, $groups, $p) {
    // Only handle @admin specially
    if ($groups[1] === 'admin') {
        $link = new Link('/admin');
        $link->appendChild(new Text('Administrator'));
        return $link;
    }
    return null;  // Let default parsing handle other @mentions
});

Override Built-in Syntax

Custom patterns are checked before built-in syntax, allowing overrides:

php
// Replace **bold** with custom brackets
$parser->addInlinePattern('/\*\*([^*]+)\*\*/', function ($match, $groups, $p) {
    return new Text('【' . $groups[1] . '】');
});

echo $converter->convert('This is **important** text.');
// Output: <p>This is 【important】 text.</p>

Custom Block Patterns

Extend Djot with custom block-level syntax by registering patterns on the BlockParser.

Admonition Blocks

For standard admonition support (note, warning, tip, etc.), use AdmonitionExtension.

For custom admonition syntax (like !!! type), use block patterns:

php
use Djot\DjotConverter;
use Djot\Node\Block\Div;

$converter = new DjotConverter();
$parser = $converter->getParser();

$parser->addBlockPattern('/^!!!\s*(note|warning|danger)\s*$/', function ($lines, $start, $parent, $p) {
    preg_match('/^!!!\s*(note|warning|danger)\s*$/', $lines[$start], $m);
    $type = $m[1];

    // Collect indented content
    $content = [];
    $i = $start + 1;
    while ($i < count($lines) && preg_match('/^\s+(.*)$/', $lines[$i], $contentMatch)) {
        $content[] = $contentMatch[1];
        $i++;
    }

    $div = new Div();
    $div->setAttribute('class', 'admonition ' . $type);
    $p->parseBlockContent($div, $content);  // Parse nested content
    $parent->appendChild($div);

    return $i - $start;  // Return lines consumed
});

$djot = <<<'DJOT'
!!! warning
    Be careful with this feature.
    It may cause issues.

Regular paragraph.
DJOT;

echo $converter->convert($djot);

Output:

html
<div class="admonition warning">
<p>Be careful with this feature.
It may cause issues.</p>
</div>
<p>Regular paragraph.</p>

Spoiler/Collapsible Blocks

Support :::spoiler ... ::: syntax:

php
$parser->addBlockPattern('/^:::spoiler\s*$/', function ($lines, $start, $parent, $p) {
    $content = [];
    $i = $start + 1;

    // Collect until closing :::
    while ($i < count($lines) && !preg_match('/^:::\s*$/', $lines[$i])) {
        $content[] = $lines[$i];
        $i++;
    }

    $div = new Div();
    $div->setAttribute('class', 'spoiler');
    $p->parseBlockContent($div, $content);
    $parent->appendChild($div);

    // +1 for closing :::
    return ($i < count($lines)) ? $i - $start + 1 : $i - $start;
});

$djot = <<<'DJOT'
:::spoiler
This content is hidden by default.

It can contain **formatted** text.
:::
DJOT;

Tab Containers

Support === Tab Title syntax:

php
$parser->addBlockPattern('/^===\s+(.+)$/', function ($lines, $start, $parent, $p) {
    preg_match('/^===\s+(.+)$/', $lines[$start], $m);
    $title = trim($m[1]);

    // Collect content until next === or end
    $content = [];
    $i = $start + 1;
    while ($i < count($lines) && !preg_match('/^===\s+/', $lines[$i])) {
        $content[] = $lines[$i];
        $i++;
    }

    $div = new Div();
    $div->setAttribute('class', 'tab');
    $div->setAttribute('data-title', $title);
    $p->parseBlockContent($div, $content);
    $parent->appendChild($div);

    return $i - $start;
});

$djot = <<<'DJOT'
=== First Tab
Content of first tab.

=== Second Tab
Content of second tab.
DJOT;

Combining Inline and Block Patterns

Use both pattern types together:

php
$converter = new DjotConverter();
$parser = $converter->getParser();
$inlineParser = $parser->getInlineParser();

// Inline: @mentions
$inlineParser->addInlinePattern('/@(\w+)/', function ($m, $g, $p) {
    $link = new Link('/u/' . $g[1]);
    $link->appendChild(new Text('@' . $g[1]));
    return $link;
});

// Block: NOTE: admonitions
$parser->addBlockPattern('/^NOTE:\s*$/', function ($lines, $start, $parent, $p) {
    $content = [];
    $i = $start + 1;
    while ($i < count($lines) && $lines[$i] !== '' && !preg_match('/^[A-Z]+:\s*$/', $lines[$i])) {
        $content[] = $lines[$i];
        $i++;
    }

    $div = new Div();
    $div->setAttribute('class', 'note');
    $p->parseBlockContent($div, $content);  // @mentions work inside!
    $parent->appendChild($div);

    return $i - $start;
});

$djot = <<<'DJOT'
NOTE:
Remember to contact @support for help.

Regular paragraph with @mention.
DJOT;

echo $converter->convert($djot);

Extracting Content Metadata

Extract metadata from Djot documents for social sharing, SEO, or other purposes by traversing the AST.

Social Meta Tags

Extract title, description, and image for Open Graph and Twitter Card tags:

php
use Djot\DjotConverter;
use Djot\Node\Block\Heading;
use Djot\Node\Block\Paragraph;
use Djot\Node\Document;
use Djot\Node\Inline\HardBreak;
use Djot\Node\Inline\Image;
use Djot\Node\Inline\SoftBreak;
use Djot\Node\Inline\Text;

function extractSocialMeta(Document $document): array
{
    $meta = [
        'title' => null,
        'description' => null,
        'image' => null,
    ];

    foreach ($document->getChildren() as $node) {
        // First heading becomes title
        if ($meta['title'] === null && $node instanceof Heading) {
            $meta['title'] = getTextContent($node);
        }

        // First paragraph becomes description
        if ($meta['description'] === null && $node instanceof Paragraph) {
            $text = getTextContent($node);
            $meta['description'] = mb_strlen($text) > 160
                ? mb_substr($text, 0, 157) . '...'
                : $text;
        }

        // First image becomes preview image
        if ($meta['image'] === null) {
            $meta['image'] = findFirstImage($node);
        }

        // Stop once we have everything
        if ($meta['title'] !== null && $meta['description'] !== null && $meta['image'] !== null) {
            break;
        }
    }

    return $meta;
}

function getTextContent($node): string
{
    $text = '';
    foreach ($node->getChildren() as $child) {
        if ($child instanceof Text) {
            $text .= $child->getContent();
        } elseif ($child instanceof SoftBreak || $child instanceof HardBreak) {
            $text .= ' ';
        } else {
            $text .= getTextContent($child);
        }
    }
    return trim($text);
}

function findFirstImage($node): ?string
{
    if ($node instanceof Image) {
        return $node->getSource();
    }
    foreach ($node->getChildren() as $child) {
        $image = findFirstImage($child);
        if ($image !== null) {
            return $image;
        }
    }
    return null;
}

// Usage
$converter = new DjotConverter();
$document = $converter->parse($djot);
$meta = extractSocialMeta($document);

Generating HTML Meta Tags

Generate Open Graph and Twitter Card markup:

php
function generateMetaTags(array $meta, string $url, string $siteName = ''): string
{
    $tags = [];
    $title = $meta['title'] ?? null;
    $description = $meta['description'] ?? null;
    $image = $meta['image'] ?? null;

    // Open Graph
    if ($title) {
        $title = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
        $tags[] = "<meta property=\"og:title\" content=\"{$title}\">";
        $tags[] = "<meta name=\"twitter:title\" content=\"{$title}\">";
    }

    if ($description) {
        $desc = htmlspecialchars($description, ENT_QUOTES, 'UTF-8');
        $tags[] = "<meta property=\"og:description\" content=\"{$desc}\">";
        $tags[] = "<meta name=\"twitter:description\" content=\"{$desc}\">";
        $tags[] = "<meta name=\"description\" content=\"{$desc}\">";
    }

    if ($image) {
        $image = htmlspecialchars($image, ENT_QUOTES, 'UTF-8');
        $tags[] = "<meta property=\"og:image\" content=\"{$image}\">";
        $tags[] = "<meta name=\"twitter:image\" content=\"{$image}\">";
        $tags[] = "<meta name=\"twitter:card\" content=\"summary_large_image\">";
    } else {
        $tags[] = "<meta name=\"twitter:card\" content=\"summary\">";
    }

    $url = htmlspecialchars($url, ENT_QUOTES, 'UTF-8');
    $tags[] = "<meta property=\"og:url\" content=\"{$url}\">";
    $tags[] = "<meta property=\"og:type\" content=\"article\">";

    if ($siteName) {
        $siteName = htmlspecialchars($siteName, ENT_QUOTES, 'UTF-8');
        $tags[] = "<meta property=\"og:site_name\" content=\"{$siteName}\">";
    }

    return implode("\n", $tags);
}

// Usage
$meta = extractSocialMeta($document);
$metaTags = generateMetaTags($meta, 'https://example.com/article', 'My Blog');

Output:

html
<meta property="og:title" content="Article Title">
<meta name="twitter:title" content="Article Title">
<meta property="og:description" content="First paragraph of the article...">
<meta name="twitter:description" content="First paragraph of the article...">
<meta name="description" content="First paragraph of the article...">
<meta property="og:image" content="https://example.com/image.jpg">
<meta name="twitter:image" content="https://example.com/image.jpg">
<meta name="twitter:card" content="summary_large_image">
<meta property="og:url" content="https://example.com/article">
<meta property="og:type" content="article">
<meta property="og:site_name" content="My Blog">

Custom Extraction with Div Attributes

Override basic extraction with explicit div attributes:

php
use Djot\Node\Block\Div;
use Djot\Node\Document;

function extractSocialMetaWithOverrides(Document $document): array
{
    // Start with basic content extraction
    $meta = extractSocialMeta($document);

    // Override with explicit div attributes if present
    foreach ($document->getChildren() as $node) {
        if ($node instanceof Div) {
            // Use div attributes: ::: {og-title="Custom Title"}
            if (($ogTitle = $node->getAttribute('og-title')) !== null) {
                $meta['title'] = $ogTitle;
            }
            if (($ogDesc = $node->getAttribute('og-description')) !== null) {
                $meta['description'] = $ogDesc;
            }
            if (($ogImage = $node->getAttribute('og-image')) !== null) {
                $meta['image'] = $ogImage;
            }
            break;
        }
    }

    return $meta;
}

Usage in Djot:

djot
::: {og-title="Custom Social Title" og-description="A custom description for social sharing"}

# Article Title

This is the article content...

:::

Released under the MIT License.