Plugins

YiiPress extension points are configured through Yii3 DI. Content processors transform Markdown and rendered HTML during builds; related-content and table-of-contents processors add shared page behavior.

Lifecycle hooks cover build-wide and render-wide plugin behavior that is outside a single content processor.

Content processors

Content processors transform entry content through a pipeline. Each processor receives the output of the previous one.

Processor interface

A processor implements YiiPress\Processor\ContentProcessorInterface:

PHP
interface ContentProcessorInterface
{
    public function process(string $content, Entry $entry): string;
}

Content processor pipeline

ContentProcessorPipeline chains processors in order:

flowchart LR markdown["Markdown"] --> markdownProcessor["MarkdownProcessor"] markdownProcessor --> syntax["SyntaxHighlightProcessor"] syntax --> custom["Custom processors"] custom --> html["Final HTML"]

Two separate pipelines are configured via the Yii3 DI container in config/common/di/content-pipeline.php:

  • contentPipeline — used by EntryRenderer: MarkdownProcessorSyntaxHighlightProcessor
  • feedPipeline — used by FeedGenerator: MarkdownProcessor only (no syntax highlighting in feeds)

Built-in processors

MarkdownProcessor

Converts Markdown to HTML using the reusable native PHP extension package, iliaal/mdparser. Accepts MarkdownConfig via constructor for feature toggles
(tables, strikethrough, tasklists, GitHub-style alerts, etc.).

The extension exposes the PHP API as MdParser\Parser and
MdParser\Options, backed by bundled MD4C sources. YiiPress
downloads the package from Packagist during image and binary builds, compiles it,
and enables it as ext-mdparser.

SyntaxHighlightProcessor

Highlights code blocks server-side during build. No client-side JavaScript is needed.

Use standard fenced code blocks with a language identifier:

MARKDOWN
```php
echo "Hello, world!";
```

The highlighter is a reusable native PHP extension package, yiipress/highlighter. The extension
defines the PHP API as YiiPress\Highlighter, builds a Rust
library with syntect and rayon,
then statically links that library into ext-highlighter. It processes all
<pre><code class="language-xxx"> blocks in the rendered HTML, replacing them with
inline-styled highlighted output. SyntaxHighlightProcessor preserves the normalized language
identifier in a surrounding <div class="code-block" data-language="php"> and emits a visible
.code-language-label, so custom themes can style or reposition the label without inspecting code.

The bundled minimal theme displays the language label and adds a client-side Copy button to
rendered code blocks. Unlabeled blocks remain unchanged and still receive a copy button.

LaTeX math

When markdown.latex_math is enabled, mdparser emits math spans as <span class="math"> elements. Display math adds the display class. YiiPress detects those elements and injects KaTeX rendering assets only on pages that contain math.

Inline math uses $...$, and display math uses $$...$$:

MARKDOWN
Euler: $e^{i\pi} + 1 = 0$

$$
\int_0^1 x^2\,dx
$$

YiiPress ships a small browser enhancer at assets/plugins/latex-math.js and loads fixed-version KaTeX CSS/JS from jsDelivr for the renderer itself.

The native extension passes explicit input and output lengths so repeated highlighting calls avoid
extra C-string scans at the PHP/Rust boundary.

Rayon parallelizes highlighting across code blocks within a single page, which helps
when a page contains many code blocks (e.g., documentation pages).

The extension is downloaded from Packagist during YiiPress image and binary builds, compiled, and enabled as ext-highlighter.
No additional setup is needed when using the YiiPress static binary.

Use it directly from PHP:

PHP
use YiiPress\Highlighter;

$html = (new Highlighter())->highlightHtml($html, 'Solarized (dark)');

Highlight raw code without wrapping it in <pre><code> first:

PHP
$html = (new Highlighter())->highlight('echo "Hello";', 'php');

Use class_exists(Highlighter::class) to detect whether the extension is loaded.

Supported languages include all syntect defaults (PHP, JavaScript, Python, Rust, YAML,
Bash, SQL, HTML, CSS, and many more). Code blocks with an unrecognized language are
highlighted as plain text.

The highlighting color scheme is configured site-wide in content/config.yaml via
highlight_theme. If omitted, YiiPress uses syntect's InspiredGitHub theme.

MermaidProcessor

Renders Mermaid diagrams on the client side.

Use fenced code blocks with mermaid language identifier:

Flowchart:

MARKDOWN
```mermaid
flowchart LR
    A[Start] --> B{Condition}
    B -->|Yes| C[Action 1]
    B -->|No| D[Action 2]
```

Sequence diagram:

MARKDOWN
```mermaid
sequenceDiagram
    Alice->>John: Hello John, how are you?
    John-->>Alice: Great!
    Alice-)John: See you later!
```

Gantt chart:

MARKDOWN
```mermaid
gantt
    title Project Timeline
    dateFormat  YYYY-MM-DD
    section Phase 1
    Task 1 :a1, 2024-01-01, 30d
    Task 2 :after a1, 20d
```

The processor converts the code block to a <div class="mermaid"> element.
Mermaid.js (loaded via CDN in the template) renders the diagram as SVG in the browser.
HTML minification preserves the diagram source whitespace because Mermaid uses line breaks to separate statements when semicolons are omitted.

Supported diagram types: flowcharts, sequence diagrams, Gantt charts, pie charts, class diagrams, state diagrams,
user journey maps, and more.

Note: Mermaid.js is only loaded on pages that contain diagrams to reduce bandwidth.

For full syntax reference, see Mermaid documentation.

YouTubeProcessor

Expands YouTube shortcodes into responsive embed HTML before markdown processing.

MARKDOWN
<div class="shortcode shortcode-youtube"><div class="video-container"><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy" title="YouTube video player"></iframe></div></div>

Optional start time (in seconds):

MARKDOWN
<div class="shortcode shortcode-youtube"><div class="video-container"><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ?start=30" width="560" height="315" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy" title="YouTube video player"></iframe></div></div>

With custom dimensions:

MARKDOWN
<div class="shortcode shortcode-youtube"><div class="video-container"><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="640" height="360" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy" title="YouTube video player"></iframe></div></div>

Generated HTML includes:

  • Responsive iframe container with .video-container wrapper
  • Lazy loading (loading="lazy")
  • Fullscreen support (allowfullscreen)
  • Accessible title attribute
  • CSS classes: .shortcode, .shortcode-youtube

VimeoProcessor

Expands Vimeo shortcodes into responsive embed HTML before markdown processing.

MARKDOWN
<div class="shortcode shortcode-vimeo"><div class="video-container"><iframe src="https://player.vimeo.com/video/123456789?dnt=1" width="560" height="315" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media" allowfullscreen loading="lazy" title="Vimeo video player"></iframe></div></div>

With custom dimensions:

MARKDOWN
<div class="shortcode shortcode-vimeo"><div class="video-container"><iframe src="https://player.vimeo.com/video/123456789?dnt=1" width="640" height="360" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media" allowfullscreen loading="lazy" title="Vimeo video player"></iframe></div></div>

Generated HTML includes:

  • Responsive iframe container with .video-container wrapper
  • Privacy-friendly embed (dnt=1 - do not track)
  • Lazy loading (loading="lazy")
  • Fullscreen support (allowfullscreen)
  • Accessible title attribute
  • CSS classes: .shortcode, .shortcode-vimeo

Both shortcode processors support:

  • Self-closing (/]) and regular syntax
  • Double quotes, single quotes, or no quotes for attribute values (no spaces)
  • Case-insensitive shortcode names

Tabbed code groups

Use [code-group] to present two or more alternative code examples as tabs. Wrap each
fenced code block in [code-tab] and set its visible tab label:

MARKDOWN
<!-- yiipress-code-group:start -->
<!-- yiipress-code-tab:bnBt -->
```sh
npm install
```
<!-- /yiipress-code-tab -->
<!-- yiipress-code-tab:cG5wbQ== -->
```sh
pnpm install
```
<!-- /yiipress-code-tab -->
<!-- yiipress-code-group:end -->

Every group must contain at least two [code-tab] shortcodes, and every tab must have a
non-empty label. Content outside the tab shortcodes is not allowed within a group.

Visitors can select tabs with a pointer or use Left/Right arrow, Home, and End keys.
When JavaScript is unavailable, all labeled examples remain visible.
The built-in processor registers its tab stylesheet and script only on pages containing a
code group. Plugin assets are copied and linked automatically, including on nested pages
and when asset fingerprinting is disabled.

FAQ question blocks

Use a [question] shortcode with a title for an answer that should be expandable
without JavaScript. The answer is normal Markdown:

MARKDOWN
[question title="How do I install YiiPress?"]
Download the binary for your platform and run **`yiipress init:content`**.
[/question]

YiiPress escapes the title and renders semantic <details> and <summary> elements.
Question shortcodes cannot be nested. Malformed, nested, and unclosed shortcodes are left
as literal content instead of consuming the rest of the page.

By default, questions remain where they are written. Set faq_level in entry front matter
to change grouping:

YAML
---
title: Troubleshooting
faq_level: 2
---
  • false or omitted — render every question inline.
  • 0 — collect every question into one FAQ section at the end of the page.
  • 1 through 6 — collect questions at the end of each matching heading section.

Grouping preserves question order. Questions before the first matching heading form a
section immediately before that heading; questions after the last matching heading form a
section at the page end. YiiPress does not emit FAQPage JSON-LD automatically because
search engines restrict FAQ rich results to specific authoritative sites and duplicated
answer data can become stale. Custom layouts may derive structured data from their own
content policy.

Project Processors

Static binary users can add site-level content processors without editing Yii3 DI configuration. Put PHP processor files in content/processors/; files matching *.php are discovered automatically, sorted by filename, and inserted before Markdown rendering in both the page and feed pipelines.

Create a no-op processor scaffold, then edit its returned callable:

SHELL
yiipress init:plugin "Badge Labels"

This creates content/processors/badge-labels.php. Pass --content-dir when the project uses a different content directory.

Each file must return either a YiiPress\Processor\ContentProcessorInterface instance or a callable with the same shape:

PHP
<?php

use YiiPress\Content\Model\Entry;

return static function (string $content, Entry $entry): string {
    return str_replace('[badge:stable]', '**Stable**', $content);
};

For more control, configure processors explicitly in content/config.yaml:

YAML
processors:
  content:
    before_markdown:
      - processors/badge.php
    after_markdown:
      - processors/html-postprocess.php
  feed:
    before_markdown:
      - processors/badge.php

Use before_markdown for processors that transform Markdown syntax. Use after_markdown for processors that transform rendered HTML. Processor paths are resolved from the content directory and must stay inside it. If the processors config key is omitted, YiiPress discovers content/processors/*.php; if processors contains a list or structured config, YiiPress uses only those files. Set processors: false to disable project processors.

TweetProcessor

Expands tweet shortcodes into Twitter embed HTML before markdown processing.
The Twitter widget JS is injected into <head> only on pages that contain tweet embeds.

MARKDOWN
<div class="shortcode shortcode-tweet"><blockquote class="twitter-tweet" data-dnt="true"><a href="https://twitter.com/i/web/status/1234567890"></a></blockquote></div>

Generated HTML includes:

  • A <blockquote class="twitter-tweet"> element with a link to the tweet
  • Privacy-friendly embed (data-dnt="true" — do not track)
  • CSS classes: .shortcode, .shortcode-tweet
  • The Twitter widget script (platform.twitter.com/widgets.js) injected once per page

OEmbedProcessor

Expands standalone provider URLs into embed HTML before markdown processing.

Providers are pluggable. Each provider implements YiiPress\Processor\OEmbed\OEmbedInterface and owns both:

  • URL matching logic
  • the generated embed HTML
PHP
interface OEmbedInterface
{
    public function supportsOEmbed(string $url): bool;

    public function replaceOEmbed(string $url): ?string;
}

The built-in shortcode processors implement OEmbedInterface directly, so each provider owns its shortcode parsing,
standalone URL matching, and generated HTML in one class.

Register providers through OEmbedProcessor in config/common/di/content-pipeline.php:

PHP
OEmbedProcessor::class => [
    'class' => OEmbedProcessor::class,
    '__construct()' => [
        Reference::to(YouTubeProcessor::class),
        Reference::to(VimeoProcessor::class),
        Reference::to(TweetProcessor::class),
    ],
],

Supported providers:

  • YouTube watch URLs (https://www.youtube.com/watch?v=...)
  • YouTube short URLs (https://youtu.be/...)
  • Vimeo video URLs (https://vimeo.com/123456789)
  • Twitter/X status URLs (https://twitter.com/.../status/..., https://x.com/.../status/...)

Example:

MARKDOWN
<div class="shortcode shortcode-youtube"><div class="video-container"><iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="560" height="315" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy" title="YouTube video player"></iframe></div></div>

<div class="shortcode shortcode-vimeo"><div class="video-container"><iframe src="https://player.vimeo.com/video/123456789?dnt=1" width="560" height="315" frameborder="0" allow="autoplay; fullscreen; picture-in-picture; clipboard-write; encrypted-media" allowfullscreen loading="lazy" title="Vimeo video player"></iframe></div></div>

<div class="shortcode shortcode-tweet"><blockquote class="twitter-tweet" data-dnt="true"><a href="https://twitter.com/i/web/status/1234567890"></a></blockquote></div>

Each URL must appear on its own line. Inline links remain unchanged.

Generated HTML uses the same wrappers and classes as the built-in shortcode processors:

  • .shortcode-youtube
  • .shortcode-vimeo
  • .shortcode-tweet

For tweet/status embeds, the existing Twitter widget script is injected automatically because the generated
HTML matches the same marker format as TweetProcessor.

Suggests other entries that share tags and categories with the current one. When enabled,
an in-memory RelatedIndex is built once per build from all indexed entries and injects a
$related variable into entry templates — a list of YiiPress\Content\Model\RelatedEntry
objects ordered by relevance.

Each related entry exposes:

  • title — source entry title
  • permalink — resolved URL
  • dateDateTimeImmutable|null
  • summary — entry summary (front matter or auto-generated)
  • score — relevance score (shared tags × tag_weight + shared categories × category_weight)

Enable in content/config.yaml (disabled by default):

YAML
related: true

Or configure:

YAML
related:
  limit: 5                     # maximum number of related entries per page (default: 5)
  tag_weight: 2                # score per shared tag (default: 2)
  category_weight: 3           # score per shared category (default: 3)
  same_collection_only: true   # only suggest entries from the same collection (default: true)

Scoring uses an inverted term → entry index, so building the full related graph runs in
time proportional to the number of term postings rather than O(N²).

Templates can render it:

PHP
<?php if (!empty($related)): ?>
<section class="related">
    <h2><?= htmlspecialchars($t('related_posts')) ?></h2>
    <ul>
        <?php foreach ($related as $item): ?>
        <li><a href="<?= htmlspecialchars($item->permalink) ?>"><?= htmlspecialchars($item->title) ?></a></li>
        <?php endforeach; ?>
    </ul>
</section>
<?php endif; ?>

The bundled minimal theme renders a localized related-posts section automatically when the
feature is enabled.

TocProcessor

Generates a table of contents from headings in the rendered HTML.

Enabled by default. Disable globally in content/config.yaml:

YAML
toc: false

When enabled, the processor:

  • Injects id attributes into all heading tags (<h1><h6>), slugified from the heading text
  • Renders a hover permalink anchor inside each heading
  • Deduplicates IDs by appending a numeric suffix (intro, intro-2, intro-3)
  • Keeps existing heading id attributes unchanged
  • Passes a $toc variable to entry templates — a list of {id, text, level} entries

Templates can render the TOC as a navigation list:

PHP
<?php if ($toc !== []): ?>
<nav class="toc">
    <ol>
        <?php foreach ($toc as $item): ?>
        <li class="toc-level-<?= $item['level'] ?>">
            <a href="#<?= htmlspecialchars($item['id']) ?>"><?= htmlspecialchars($item['text']) ?></a>
        </li>
        <?php endforeach; ?>
    </ol>
</nav>
<?php endif; ?>

Writing a custom processor

Create a class implementing ContentProcessorInterface. For example, a shortcode processor:

PHP
final class ShortcodeProcessor implements ContentProcessorInterface
{
    public function process(string $content, Entry $entry): string
    {
        return preg_replace(
            '/\<div class="shortcode shortcode-youtube"><div class="video-container"><iframe src="https://www.youtube.com/embed/([^" width="560" height="315" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen loading="lazy" title="YouTube video player"></iframe></div></div>+)"\/\]/',
            '<div class="video"><iframe src="https://www.youtube.com/embed/$1"></iframe></div>',
            $content,
        );
    }
}

To register it, add it to config/common/di/content-pipeline.php. Place it before MarkdownProcessor since it operates on markdown:

PHP
return [
    ContentProcessorPipeline::class => [
        '__construct()' => [
            new ShortcodeProcessor(),
            new MarkdownProcessor(),
            new SyntaxHighlightProcessor(),
        ],
    ],
];

Processor order matters — each processor receives the output of the previous one.

Lifecycle hooks

Lifecycle hooks use PSR-14 events through yiisoft/yii-event and yiisoft/event-dispatcher. They are intended for plugin behavior that needs build lifecycle context, final page HTML, or build-wide side effects.

Available events:

  • BuildStartedEvent — fired after site config, navigation, collections, and authors are parsed, before entries and support files are written
  • BuildFinishedEvent — fired after a successful build, once entries, support files, assets, sitemap, search index, taxonomies, and author pages are generated
  • RenderStartedEvent — fired before an entry or standalone page render starts
  • RenderFinishedEvent — fired after final page HTML is rendered; listeners may replace the HTML via setHtml()

Register listeners in the Yii event configuration group:

PHP
// config/common/events.php
use YiiPress\Hook\BuildFinishedEvent;
use YiiPress\Hook\RenderFinishedEvent;

return [
    RenderFinishedEvent::class => [
        static function (RenderFinishedEvent $event): void {
            $event->setHtml(str_replace('</body>', '<!-- generated by plugin --></body>', $event->html()));
        },
    ],
    BuildFinishedEvent::class => [
        static function (BuildFinishedEvent $event): void {
            file_put_contents($event->context->outputDir . '/plugin.txt', 'done');
        },
    ],
];

Then include that file in config/configuration.php:

PHP
'events' => 'common/events.php',

BuildContext exposes rootPath, contentDir, outputDir, worker count, draft/future flags, and build mode flags. Render events expose the current SiteConfig, Entry, and permalink.

BuildFinishedEvent is a successful-build event. If the build throws before completion, YiiPress does not dispatch it.

Render events are dispatched in the process that renders the entry. With multiple workers, this is a forked worker process. RenderFinishedEvent::setHtml() works in parallel builds because the worker writes the returned HTML, but listener-owned in-memory aggregation such as counters, collected permalinks, or object mutations is not visible in the parent process. Use build-level events, external storage, or a single worker for aggregation that must survive the whole build.

When no event dispatcher is injected, hooks use a fast null path and do not allocate per-render event objects. In the default YiiPress app configuration, yiisoft/yii-event provides the dispatcher and empty listener collection.