Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions benchmarks/ProjectShortcodeProcessorBench.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace YiiPress\Benchmarks;

use DateTimeImmutable;
use PhpBench\Attributes\AfterMethods;
use PhpBench\Attributes\BeforeMethods;
use PhpBench\Attributes\Iterations;
use PhpBench\Attributes\Revs;
use PhpBench\Attributes\Warmup;
use YiiPress\Content\Model\Entry;
use YiiPress\Processor\Shortcode\ProjectShortcodeProcessor;

#[BeforeMethods('setUp')]
#[AfterMethods('tearDown')]
final class ProjectShortcodeProcessorBench
{
private string $contentDir;
private Entry $entry;
private ProjectShortcodeProcessor $processor;
private string $content;

public function setUp(): void
{
$this->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', '<?php return "**" . ($attributes["label"] ?? "") . "**";');

$file = $this->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);
}
}
3 changes: 3 additions & 0 deletions config/common/di/content-pipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -49,6 +51,7 @@
'feedPipeline' => [
'class' => ContentProcessorPipeline::class,
'__construct()' => [
Reference::to(ProjectShortcodeProcessor::class),
Reference::to(MarkdownProcessor::class),
Reference::to(TagLinkProcessor::class),
],
Expand Down
39 changes: 39 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php
// content/shortcodes/badge.php
return '**' . ($attributes['label'] ?? '') . '**';
```

```php
<?php
// content/shortcodes/callout.php
?>
<aside class="callout">
<strong><?= htmlspecialchars($attributes['title'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE) ?></strong>
<?= $content ?>
</aside>
```

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.
Expand Down
1 change: 1 addition & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions src/Processor/Shortcode/ProjectShortcodeProcessor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

declare(strict_types=1);

namespace YiiPress\Processor\Shortcode;

use Throwable;
use YiiPress\Content\Model\Entry;
use YiiPress\Processor\ContentProcessorInterface;

use function dirname;
use function is_dir;
use function is_file;
use function ob_get_clean;
use function ob_start;
use function preg_replace_callback;
use function str_contains;

/**
* Expands site-level shortcodes from content/shortcodes/*.php before markdown processing.
*/
final class ProjectShortcodeProcessor implements ContentProcessorInterface
{
use ParsesShortcodeAttributesTrait;

private const string BLOCK_PATTERN = '/\{\{<\s*([A-Za-z][A-Za-z0-9_-]*)\b([^>]*)>\}\}(.*?)\{\{<\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<string, string> $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;
}
}
Loading
Loading