diff --git a/benchmarks/ProjectShortcodeProcessorBench.php b/benchmarks/ProjectShortcodeProcessorBench.php
new file mode 100644
index 0000000..8c89487
--- /dev/null
+++ b/benchmarks/ProjectShortcodeProcessorBench.php
@@ -0,0 +1,99 @@
+contentDir = sys_get_temp_dir() . '/yiipress-shortcode-bench-' . uniqid();
+ mkdir($this->contentDir . '/blog', 0o755, true);
+ mkdir($this->contentDir . '/shortcodes', 0o755, true);
+ file_put_contents($this->contentDir . '/config.yaml', "title: Test\n");
+ file_put_contents($this->contentDir . '/shortcodes/badge.php', 'contentDir . '/blog/post.md';
+ file_put_contents($file, "---\ntitle: Test\n---\nBody.");
+ $this->entry = new Entry(
+ filePath: $file,
+ collection: 'blog',
+ slug: 'post',
+ title: 'Post',
+ date: new DateTimeImmutable('2024-01-01'),
+ draft: false,
+ tags: [],
+ categories: [],
+ authors: [],
+ summary: '',
+ permalink: '',
+ layout: '',
+ theme: '',
+ weight: 0,
+ language: '',
+ redirectTo: '',
+ extra: [],
+ bodyOffset: 0,
+ bodyLength: 0,
+ );
+
+ $parts = [];
+ for ($i = 1; $i <= 100; $i++) {
+ $parts[] = 'Item ' . $i . ': {{< badge label="Stable" >}}';
+ }
+
+ $this->content = implode("\n", $parts);
+ $this->processor = new ProjectShortcodeProcessor();
+ }
+
+ public function tearDown(): void
+ {
+ $this->removeDir($this->contentDir);
+ }
+
+ #[Revs(100)]
+ #[Iterations(3)]
+ #[Warmup(1)]
+ public function benchExpandProjectShortcodes(): void
+ {
+ $this->processor->process($this->content, $this->entry);
+ }
+
+ private function removeDir(string $path): void
+ {
+ if (!is_dir($path)) {
+ return;
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST,
+ );
+ foreach ($iterator as $item) {
+ if ($item->isDir()) {
+ rmdir($item->getPathname());
+ } else {
+ unlink($item->getPathname());
+ }
+ }
+
+ rmdir($path);
+ }
+}
diff --git a/config/common/di/content-pipeline.php b/config/common/di/content-pipeline.php
index 0a1ca6d..46d760f 100644
--- a/config/common/di/content-pipeline.php
+++ b/config/common/di/content-pipeline.php
@@ -11,6 +11,7 @@
use YiiPress\Processor\ContentProcessorPipeline;
use YiiPress\Processor\Mermaid\MermaidProcessor;
use YiiPress\Processor\OEmbed\OEmbedProcessor;
+use YiiPress\Processor\Shortcode\ProjectShortcodeProcessor;
use YiiPress\Processor\Shortcode\TweetProcessor;
use YiiPress\Processor\Shortcode\VimeoProcessor;
use YiiPress\Processor\Shortcode\YouTubeProcessor;
@@ -38,6 +39,7 @@
Reference::to(YouTubeProcessor::class),
Reference::to(VimeoProcessor::class),
Reference::to(TweetProcessor::class),
+ Reference::to(ProjectShortcodeProcessor::class),
Reference::to(OEmbedProcessor::class),
Reference::to(MarkdownProcessor::class),
Reference::to(TagLinkProcessor::class),
@@ -49,6 +51,7 @@
'feedPipeline' => [
'class' => ContentProcessorPipeline::class,
'__construct()' => [
+ Reference::to(ProjectShortcodeProcessor::class),
Reference::to(MarkdownProcessor::class),
Reference::to(TagLinkProcessor::class),
],
diff --git a/docs/plugins.md b/docs/plugins.md
index 63b7dbc..542c7c6 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -198,6 +198,45 @@ Both shortcode processors support:
- Double quotes, single quotes, or no quotes for attribute values (no spaces)
- Case-insensitive shortcode names
+### Project Shortcodes
+
+Static binary users can define site-level shortcodes without editing Yii3 DI configuration. Create PHP templates in `content/shortcodes/` and call them from Markdown with Hugo-style syntax:
+
+```markdown
+{{< badge label="Stable" >}}
+
+{{< callout title="Note" >}}
+Markdown **inside** the shortcode stays available to the template.
+{{< /callout >}}
+```
+
+Template files are named after the shortcode:
+
+```php
+
+
+```
+
+Shortcode templates receive:
+
+- `$name` — shortcode name
+- `$attributes` — parsed key/value attributes
+- `$content` — block shortcode inner content, or an empty string for inline shortcodes
+- `$entry` — current `YiiPress\Content\Model\Entry`
+
+Templates may return a string or echo output. The result is inserted before Markdown rendering, so templates can emit either Markdown or HTML. Unknown shortcodes are left unchanged.
+
### TweetProcessor
Expands tweet shortcodes into Twitter embed HTML before markdown processing.
diff --git a/roadmap.md b/roadmap.md
index f36c641..915b025 100644
--- a/roadmap.md
+++ b/roadmap.md
@@ -91,6 +91,7 @@
## Priority 7: Content extensions
- [x] Built-in shortcodes (YouTube, Vimeo, figure, etc.) as a plugin
+- [x] Project-level shortcodes from `content/shortcodes` for binary users
- [x] Table of contents generation from headings as a plugin
- [x] Diagram support (Mermaid) as a plugin
- [x] oEmbed support (auto-expanding URLs to embeds) as a plugin
diff --git a/src/Processor/Shortcode/ProjectShortcodeProcessor.php b/src/Processor/Shortcode/ProjectShortcodeProcessor.php
new file mode 100644
index 0000000..3830ab9
--- /dev/null
+++ b/src/Processor/Shortcode/ProjectShortcodeProcessor.php
@@ -0,0 +1,122 @@
+]*)>\}\}(.*?)\{\{<\s*\/\1\s*>\}\}/s';
+ private const string INLINE_PATTERN = '/\{\{<\s*([A-Za-z][A-Za-z0-9_-]*)\b([^>]*)\/?\s*>\}\}/';
+
+ public function process(string $content, Entry $entry): string
+ {
+ if (!str_contains($content, '{{<')) {
+ return $content;
+ }
+
+ $shortcodeDir = $this->shortcodeDirectory($entry);
+ if ($shortcodeDir === '') {
+ return $content;
+ }
+
+ $content = (string) preg_replace_callback(
+ self::BLOCK_PATTERN,
+ fn (array $matches): string => $this->renderOrOriginal(
+ $shortcodeDir,
+ $matches[1],
+ $matches[2],
+ $entry,
+ $matches[3],
+ $matches[0],
+ ),
+ $content,
+ );
+
+ return (string) preg_replace_callback(
+ self::INLINE_PATTERN,
+ fn (array $matches): string => $this->renderOrOriginal(
+ $shortcodeDir,
+ $matches[1],
+ $matches[2],
+ $entry,
+ '',
+ $matches[0],
+ ),
+ $content,
+ );
+ }
+
+ private function shortcodeDirectory(Entry $entry): string
+ {
+ $directory = dirname($entry->filePath);
+ while (true) {
+ $candidate = $directory . '/shortcodes';
+ if (is_dir($candidate)) {
+ return $candidate;
+ }
+
+ if (is_file($directory . '/config.yaml')) {
+ return '';
+ }
+
+ $parent = dirname($directory);
+ if ($parent === $directory) {
+ return '';
+ }
+
+ $directory = $parent;
+ }
+ }
+
+ private function renderOrOriginal(
+ string $shortcodeDir,
+ string $name,
+ string $attributeString,
+ Entry $entry,
+ string $content,
+ string $original,
+ ): string {
+ $template = $shortcodeDir . '/' . $name . '.php';
+ if (!is_file($template)) {
+ return $original;
+ }
+
+ return $this->render($template, $name, $this->parseAttributes($attributeString), $content, $entry);
+ }
+
+ /**
+ * @param array $attributes
+ */
+ private function render(string $template, string $name, array $attributes, string $content, Entry $entry): string
+ {
+ ob_start();
+ try {
+ /** @psalm-suppress UnresolvableInclude User-defined shortcode templates are resolved at build time. */
+ $result = require $template;
+ $output = (string) ob_get_clean();
+ } catch (Throwable $throwable) {
+ ob_get_clean();
+ throw $throwable;
+ }
+
+ return $result === 1 ? $output : $output . (string) $result;
+ }
+}
diff --git a/tests/Unit/Processor/ProjectShortcodeProcessorTest.php b/tests/Unit/Processor/ProjectShortcodeProcessorTest.php
new file mode 100644
index 0000000..4a18656
--- /dev/null
+++ b/tests/Unit/Processor/ProjectShortcodeProcessorTest.php
@@ -0,0 +1,132 @@
+contentDir = sys_get_temp_dir() . '/yiipress-shortcodes-' . uniqid();
+ mkdir($this->contentDir . '/blog', 0o755, true);
+ mkdir($this->contentDir . '/shortcodes', 0o755, true);
+ file_put_contents($this->contentDir . '/config.yaml', "title: Test\n");
+ }
+
+ protected function tearDown(): void
+ {
+ $this->removeDir($this->contentDir);
+ }
+
+ public function testRendersInlineShortcodeTemplateReturnValue(): void
+ {
+ file_put_contents(
+ $this->contentDir . '/shortcodes/badge.php',
+ 'process(
+ 'Status: {{< badge label="Stable" >}}',
+ $this->entry(),
+ );
+
+ assertSame('Status: **Stable**', $result);
+ }
+
+ public function testRendersBlockShortcodeWithContent(): void
+ {
+ file_put_contents(
+ $this->contentDir . '/shortcodes/callout.php',
+ '" . ($attributes["title"] ?? "") . "" . $content . "";',
+ );
+
+ $result = (new ProjectShortcodeProcessor())->process(
+ "{{< callout title='Note' >}}Body **text**.{{< /callout >}}",
+ $this->entry(),
+ );
+
+ assertSame('', $result);
+ }
+
+ public function testUsesEchoedTemplateOutput(): void
+ {
+ file_put_contents(
+ $this->contentDir . '/shortcodes/echo.php',
+ '" . $name . "";',
+ );
+
+ $result = (new ProjectShortcodeProcessor())->process('{{< echo >}}', $this->entry());
+
+ assertSame('echo', $result);
+ }
+
+ public function testLeavesUnknownShortcodesUnchanged(): void
+ {
+ $result = (new ProjectShortcodeProcessor())->process('Hello {{< missing value="1" >}}', $this->entry());
+
+ assertSame('Hello {{< missing value="1" >}}', $result);
+ }
+
+ private function entry(): Entry
+ {
+ $file = $this->contentDir . '/blog/post.md';
+ file_put_contents($file, "---\ntitle: Test\n---\nBody.");
+
+ return new Entry(
+ filePath: $file,
+ collection: 'blog',
+ slug: 'post',
+ title: 'Post',
+ date: new DateTimeImmutable('2024-01-01'),
+ draft: false,
+ tags: [],
+ categories: [],
+ authors: [],
+ summary: '',
+ permalink: '',
+ layout: '',
+ theme: '',
+ weight: 0,
+ language: '',
+ redirectTo: '',
+ extra: [],
+ bodyOffset: 0,
+ bodyLength: 0,
+ );
+ }
+
+ private function removeDir(string $path): void
+ {
+ if (!is_dir($path)) {
+ return;
+ }
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::CHILD_FIRST,
+ );
+ foreach ($iterator as $item) {
+ /** @var SplFileInfo $item */
+ if ($item->isDir()) {
+ rmdir($item->getPathname());
+ } else {
+ unlink($item->getPathname());
+ }
+ }
+ rmdir($path);
+ }
+}