>|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('/(?> */ 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} */ 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 */ 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 $match @param array $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 $definition @param array $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 ''; } } $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 $attributes @param mixed $rules @return array */ 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 $definition @param array $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" . '
' . "\n\n"; } /** @param array $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 $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'); } }