Files
tcms/app/Shortcodes.php
Ty Clifford 9dbd29d8aa - Shortcodes
2026-07-04 21:53:18 -04:00

271 lines
9.8 KiB
PHP

<?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');
}
}