- Shortcodes
This commit is contained in:
@@ -11,6 +11,7 @@ final class Markdown
|
||||
if ($executePhp) {
|
||||
$markdown = self::executePhp($markdown);
|
||||
}
|
||||
$markdown = Shortcodes::render($markdown);
|
||||
$markdown = trim($markdown);
|
||||
if ($markdown === '') {
|
||||
return '';
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace NeonBlog;
|
||||
|
||||
final class Shortcodes
|
||||
{
|
||||
/** @var array<string, array<string, mixed>>|null */
|
||||
private static ?array $definitions = null;
|
||||
|
||||
public static function render(string $source): string
|
||||
{
|
||||
$definitions = self::definitions();
|
||||
if ($definitions === [] || !str_contains($source, '[')) {
|
||||
return $source;
|
||||
}
|
||||
|
||||
[$source, $placeholders] = self::protectCode($source);
|
||||
$source = preg_replace_callback('/(?<!\\\\)\[([A-Za-z][A-Za-z0-9_-]*)(?:=([^\]\s]+)|\s+([^\]]+))?\]/', static function (array $matches) use ($definitions): string {
|
||||
$name = strtolower($matches[1]);
|
||||
if (!isset($definitions[$name])) {
|
||||
return $matches[0];
|
||||
}
|
||||
|
||||
$attributes = self::parseAttributes($matches[2] ?? '', $matches[3] ?? '');
|
||||
return self::renderDefinition($name, $definitions[$name], $attributes, $matches[0]);
|
||||
}, $source) ?? $source;
|
||||
|
||||
return strtr($source, $placeholders);
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>> */
|
||||
private static function definitions(): array
|
||||
{
|
||||
if (self::$definitions !== null) {
|
||||
return self::$definitions;
|
||||
}
|
||||
|
||||
$root = defined('BLOG_ROOT') ? BLOG_ROOT : dirname(__DIR__);
|
||||
$definitions = [];
|
||||
|
||||
$phpFile = $root . '/config/shortcodes.php';
|
||||
if (is_file($phpFile)) {
|
||||
$loaded = require $phpFile;
|
||||
if (is_array($loaded)) {
|
||||
$definitions = $loaded;
|
||||
}
|
||||
}
|
||||
|
||||
$jsonFile = $root . '/config/shortcodes.json';
|
||||
if (is_file($jsonFile)) {
|
||||
$decoded = json_decode((string) file_get_contents($jsonFile), true);
|
||||
if (is_array($decoded)) {
|
||||
$definitions = array_replace_recursive($definitions, $decoded);
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = [];
|
||||
foreach ($definitions as $name => $definition) {
|
||||
if (is_string($name) && is_array($definition)) {
|
||||
$normalized[strtolower($name)] = $definition;
|
||||
}
|
||||
}
|
||||
|
||||
return self::$definitions = $normalized;
|
||||
}
|
||||
|
||||
/** @return array{0: string, 1: array<string, string>} */
|
||||
private static function protectCode(string $source): array
|
||||
{
|
||||
$placeholders = [];
|
||||
$source = preg_replace_callback('/```[\s\S]*?```/', static function (array $matches) use (&$placeholders): string {
|
||||
$key = "\x1CSHORTCODE" . count($placeholders) . "\x1C";
|
||||
$placeholders[$key] = $matches[0];
|
||||
return $key;
|
||||
}, $source) ?? $source;
|
||||
$source = preg_replace_callback('/`[^`\n]*`/', static function (array $matches) use (&$placeholders): string {
|
||||
$key = "\x1CSHORTCODE" . count($placeholders) . "\x1C";
|
||||
$placeholders[$key] = $matches[0];
|
||||
return $key;
|
||||
}, $source) ?? $source;
|
||||
|
||||
return [$source, $placeholders];
|
||||
}
|
||||
|
||||
/** @return array<string, string> */
|
||||
private static function parseAttributes(string $value, string $arguments): array
|
||||
{
|
||||
$attributes = [];
|
||||
$value = trim($value);
|
||||
if ($value !== '') {
|
||||
$attributes['value'] = self::unquote($value);
|
||||
}
|
||||
|
||||
if (trim($arguments) === '') {
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
preg_match_all('/([A-Za-z_][A-Za-z0-9_-]*)=(?:"([^"]*)"|\'([^\']*)\'|([^\s]+))|(?:"([^"]*)"|\'([^\']*)\'|([^\s]+))/', $arguments, $matches, PREG_SET_ORDER);
|
||||
foreach ($matches as $match) {
|
||||
if (($match[1] ?? '') !== '') {
|
||||
$attributes[strtolower($match[1])] = self::firstMatch($match, [2, 3, 4]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isset($attributes['value'])) {
|
||||
$attributes['value'] = self::firstMatch($match, [5, 6, 7]);
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/** @param array<int, string> $match @param array<int, int> $indexes */
|
||||
private static function firstMatch(array $match, array $indexes): string
|
||||
{
|
||||
foreach ($indexes as $index) {
|
||||
if (isset($match[$index]) && $match[$index] !== '') {
|
||||
return $match[$index];
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function unquote(string $value): string
|
||||
{
|
||||
if (strlen($value) >= 2) {
|
||||
$first = $value[0];
|
||||
$last = $value[strlen($value) - 1];
|
||||
if (($first === '"' && $last === '"') || ($first === "'" && $last === "'")) {
|
||||
return substr($value, 1, -1);
|
||||
}
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $definition @param array<string, string> $attributes */
|
||||
private static function renderDefinition(string $name, array $definition, array $attributes, string $fallback): string
|
||||
{
|
||||
$attributes = self::sanitizeAttributes($attributes, $definition['sanitize'] ?? []);
|
||||
foreach ((array) ($definition['required'] ?? []) as $required) {
|
||||
if ((string) ($attributes[(string) $required] ?? '') === '') {
|
||||
return '<!-- shortcode ' . self::e($name) . ' missing ' . self::e((string) $required) . ' -->';
|
||||
}
|
||||
}
|
||||
|
||||
$type = strtolower((string) ($definition['type'] ?? 'html'));
|
||||
if ($type === 'iframe') {
|
||||
return self::renderIframe($definition, $attributes);
|
||||
}
|
||||
|
||||
$template = (string) ($definition['template'] ?? '');
|
||||
if ($template === '') {
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
return self::replacePlaceholders($template, $attributes, true);
|
||||
}
|
||||
|
||||
/** @param array<string, string> $attributes @param mixed $rules @return array<string, string> */
|
||||
private static function sanitizeAttributes(array $attributes, mixed $rules): array
|
||||
{
|
||||
$rules = is_array($rules) ? $rules : [];
|
||||
foreach ($attributes as $key => $value) {
|
||||
$rule = (string) ($rules[$key] ?? 'text');
|
||||
$attributes[$key] = self::sanitize($value, $rule);
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
private static function sanitize(string $value, string $rule): string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($rule === 'youtube_id') {
|
||||
$parts = parse_url($value);
|
||||
if (isset($parts['query'])) {
|
||||
parse_str($parts['query'], $query);
|
||||
$value = (string) ($query['v'] ?? $value);
|
||||
} elseif (isset($parts['host'], $parts['path']) && str_contains($parts['host'], 'youtu.be')) {
|
||||
$value = trim($parts['path'], '/');
|
||||
} elseif (isset($parts['path']) && str_contains($parts['path'], '/embed/')) {
|
||||
$value = basename($parts['path']);
|
||||
}
|
||||
|
||||
return substr(preg_replace('/[^A-Za-z0-9_-]/', '', $value) ?? '', 0, 80);
|
||||
}
|
||||
|
||||
if ($rule === 'media_url' || $rule === 'url') {
|
||||
if (preg_match('/[\x00-\x1F\x7F]/', $value) || preg_match('/^\s*(javascript|data):/i', $value)) {
|
||||
return '';
|
||||
}
|
||||
return $value;
|
||||
}
|
||||
|
||||
return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value) ?? '';
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $definition @param array<string, string> $attributes */
|
||||
private static function renderIframe(array $definition, array $attributes): string
|
||||
{
|
||||
$src = self::replacePlaceholders((string) ($definition['src'] ?? ''), $attributes, false);
|
||||
if ($src === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$query = [];
|
||||
foreach ((array) ($definition['query'] ?? []) as $key => $template) {
|
||||
$value = self::replacePlaceholders((string) $template, $attributes, false);
|
||||
if ($value !== '') {
|
||||
$query[(string) $key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($query !== []) {
|
||||
$src = self::isAbsoluteUrl($src) ? self::appendQuery($src, $query) : View::url($src, $query);
|
||||
} elseif (!self::isAbsoluteUrl($src) && !str_starts_with($src, '/')) {
|
||||
$src = View::url($src);
|
||||
}
|
||||
|
||||
$iframeAttributes = ['src' => $src];
|
||||
foreach ((array) ($definition['attributes'] ?? []) as $key => $value) {
|
||||
$iframeAttributes[(string) $key] = is_string($value)
|
||||
? self::replacePlaceholders($value, $attributes, false)
|
||||
: $value;
|
||||
}
|
||||
|
||||
$htmlAttributes = [];
|
||||
foreach ($iframeAttributes as $key => $value) {
|
||||
if ($value === false || $value === null || $value === '') {
|
||||
continue;
|
||||
}
|
||||
if ($value === true) {
|
||||
$htmlAttributes[] = self::e((string) $key);
|
||||
continue;
|
||||
}
|
||||
$htmlAttributes[] = self::e((string) $key) . '="' . self::e((string) $value) . '"';
|
||||
}
|
||||
|
||||
$class = (string) ($definition['class'] ?? 'shortcode-embed');
|
||||
return "\n\n" . '<div class="' . self::e($class) . '"><iframe ' . implode(' ', $htmlAttributes) . '></iframe></div>' . "\n\n";
|
||||
}
|
||||
|
||||
/** @param array<string, string> $attributes */
|
||||
private static function replacePlaceholders(string $template, array $attributes, bool $escape): string
|
||||
{
|
||||
return preg_replace_callback('/\{([A-Za-z_][A-Za-z0-9_-]*)\}/', static function (array $matches) use ($attributes, $escape): string {
|
||||
$value = (string) ($attributes[strtolower($matches[1])] ?? '');
|
||||
return $escape ? self::e($value) : $value;
|
||||
}, $template) ?? $template;
|
||||
}
|
||||
|
||||
/** @param array<string, string> $query */
|
||||
private static function appendQuery(string $url, array $query): string
|
||||
{
|
||||
return $url . (str_contains($url, '?') ? '&' : '?') . http_build_query($query);
|
||||
}
|
||||
|
||||
private static function isAbsoluteUrl(string $url): bool
|
||||
{
|
||||
return (bool) preg_match('#^https?://#i', $url);
|
||||
}
|
||||
|
||||
private static function e(string $value): string
|
||||
{
|
||||
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user