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); + } +}