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:
interface ContentProcessorInterface { public function process(string $content, Entry $entry): string; }
Content processor pipeline
ContentProcessorPipeline chains processors in order:
Two separate pipelines are configured via the Yii3 DI container in config/common/di/content-pipeline.php:
- contentPipeline — used by
EntryRenderer:MarkdownProcessor→SyntaxHighlightProcessor - feedPipeline — used by
FeedGenerator:MarkdownProcessoronly (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:
```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 $$...$$:
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:
use YiiPress\Highlighter; $html = (new Highlighter())->highlightHtml($html, 'Solarized (dark)');
Highlight raw code without wrapping it in <pre><code> first:
$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 with Pele.
Use fenced code blocks with mermaid language identifier:
Flowchart:
```mermaid flowchart LR A[Start] --> B{Condition} B -->|Yes| C[Action 1] B -->|No| D[Action 2] ```
Sequence diagram:
```mermaid sequenceDiagram Alice->>John: Hello John, how are you? John-->>Alice: Great! Alice-)John: See you later! ```
Gantt chart:
```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 and keeps the diagram source HTML-escaped. Pele (loaded from jsDelivr, pinned with a Subresource Integrity hash) renders the diagram as SVG in the browser. HTML minification preserves the diagram source whitespace because Mermaid syntax uses line breaks to separate statements when semicolons are omitted.
Pele supports every diagram type built into Mermaid 12.1.0: flowcharts, sequence diagrams, Gantt charts, pie charts, class diagrams, state diagrams, user journey maps, and more. See its compatibility page for the differences from Mermaid.js: Pele uses its own layout engine, ignores Mermaid themeVariables, draws labels as SVG text (<br> line breaks, bold and italic; no arbitrary HTML), opens click links with http, https, mailto and tel URLs, and ignores JavaScript callbacks.
Diagrams fit the width of their container. On narrow screens Pele may wrap labels or switch a wide flowchart LR to a vertical layout. Diagrams that shrink to fit can be zoomed with pinch, Ctrl/⌘ + wheel, double-click or the +, -, 0 and arrow keys when focused.
Colors and fonts come from CSS variables. assets/plugins/mermaid.css maps the --pele-* variables to the theme's --c-* colors and --font-sans/--font-mono, so diagrams follow the light and dark modes without re-rendering. Override any --pele-* variable on .mermaid in a theme stylesheet to restyle diagrams; the full list is on the Pele theming page.
Note: Pele 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.
<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):
<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:
<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-containerwrapper - 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.
<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:
<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-containerwrapper - 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:
<!-- 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:
[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:
--- title: Troubleshooting faq_level: 2 ---
falseor omitted — render every question inline.0— collect every question into one FAQ section at the end of the page.1through6— 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.
Shortcode passes in custom pipelines
QuestionProcessor and CodeGroupProcessor preserve shortcode metadata before Markdown. Call withRendering() to obtain a separate instance that renders the preserved metadata after Markdown. The original instance stays in preservation mode. Custom pipelines that previously used the same instance twice must use the rendering instance for the second pass:
$questions = new QuestionProcessor(); $groups = new CodeGroupProcessor(); $pipeline = new ContentProcessorPipeline( $groups, $questions, new MarkdownProcessor(new MarkdownRenderer()), // Place syntax highlighting and other HTML processors here. $groups->withRendering(), $questions->withRendering(), );
The configured page and feed pipelines already use separate instances. Code groups render after syntax highlighting in the page pipeline; the feed pipeline includes question processing only. The code-group rendering instance supplies the head stylesheet and script. Preservation instances do not emit head assets. Rendering instances leave malformed shortcode text unchanged and never parse it again.
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. The processors/ directory is never copied to the built site, so processor source stays private.
The first build after upgrading from a version that published processors/ rebuilds the output directory to remove previously copied processor files, even when the content is unchanged.
Create a no-op processor scaffold, then edit its returned callable:
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 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:
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.
<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. The processor runs twice: before Markdown it preserves embeds in HTML-comment placeholders, and immediately after Markdown it restores the final provider HTML. This prevents Markdown rendering from escaping iframe-based embeds.
Providers are pluggable. Each provider implements YiiPress\Processor\OEmbed\OEmbedInterface and owns both:
- URL matching logic
- the generated embed HTML
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:
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:
<!-- yiipress-oembed:PGRpdiBjbGFzcz0ic2hvcnRjb2RlIHNob3J0Y29kZS15b3V0dWJlIj48ZGl2IGNsYXNzPSJ2aWRlby1jb250YWluZXIiPjxpZnJhbWUgc3JjPSJodHRwczovL3d3dy55b3V0dWJlLmNvbS9lbWJlZC9kUXc0dzlXZ1hjUSIgd2lkdGg9IjU2MCIgaGVpZ2h0PSIzMTUiIGZyYW1lYm9yZGVyPSIwIiBhbGxvdz0iYWNjZWxlcm9tZXRlcjsgYXV0b3BsYXk7IGNsaXBib2FyZC13cml0ZTsgZW5jcnlwdGVkLW1lZGlhOyBneXJvc2NvcGU7IHBpY3R1cmUtaW4tcGljdHVyZTsgd2ViLXNoYXJlIiBhbGxvd2Z1bGxzY3JlZW4gbG9hZGluZz0ibGF6eSIgdGl0bGU9IllvdVR1YmUgdmlkZW8gcGxheWVyIj48L2lmcmFtZT48L2Rpdj48L2Rpdj4= --> <!-- yiipress-oembed:PGRpdiBjbGFzcz0ic2hvcnRjb2RlIHNob3J0Y29kZS12aW1lbyI+PGRpdiBjbGFzcz0idmlkZW8tY29udGFpbmVyIj48aWZyYW1lIHNyYz0iaHR0cHM6Ly9wbGF5ZXIudmltZW8uY29tL3ZpZGVvLzEyMzQ1Njc4OT9kbnQ9MSIgd2lkdGg9IjU2MCIgaGVpZ2h0PSIzMTUiIGZyYW1lYm9yZGVyPSIwIiBhbGxvdz0iYXV0b3BsYXk7IGZ1bGxzY3JlZW47IHBpY3R1cmUtaW4tcGljdHVyZTsgY2xpcGJvYXJkLXdyaXRlOyBlbmNyeXB0ZWQtbWVkaWEiIGFsbG93ZnVsbHNjcmVlbiBsb2FkaW5nPSJsYXp5IiB0aXRsZT0iVmltZW8gdmlkZW8gcGxheWVyIj48L2lmcmFtZT48L2Rpdj48L2Rpdj4= --> <!-- yiipress-oembed:PGRpdiBjbGFzcz0ic2hvcnRjb2RlIHNob3J0Y29kZS10d2VldCI+PGJsb2NrcXVvdGUgY2xhc3M9InR3aXR0ZXItdHdlZXQiIGRhdGEtZG50PSJ0cnVlIj48YSBocmVmPSJodHRwczovL3R3aXR0ZXIuY29tL2kvd2ViL3N0YXR1cy8xMjM0NTY3ODkwIj48L2E+PC9ibG9ja3F1b3RlPjwvZGl2Pg== -->
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.
Related content
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 titlepermalink— resolved URLdate—DateTimeImmutable|nullsummary— 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):
related: true
Or configure:
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 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:
toc: false
When enabled, the processor:
- Injects
idattributes 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
idattributes unchanged - Passes a
$tocvariable to entry templates — a list of{id, text, level}entries
Templates can render the TOC as a navigation list:
<?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:
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:
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 writtenBuildFinishedEvent— fired after a successful build, once entries, support files, assets, sitemap, search index, taxonomies, and author pages are generatedRenderStartedEvent— fired before an entry or standalone page render startsRenderFinishedEvent— fired after final page HTML is rendered; listeners may replace the HTML viasetHtml()
Register listeners in the Yii event configuration group:
// 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:
'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 an independent 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.