From 9dbd29d8aa6c8928478a46ec1e259346c5288990 Mon Sep 17 00:00:00 2001 From: Ty Clifford Date: Sat, 4 Jul 2026 21:53:18 -0400 Subject: [PATCH] - Shortcodes --- CHANGELOG.md | 7 + README.md | 9 ++ app/Markdown.php | 1 + app/Shortcodes.php | 270 +++++++++++++++++++++++++++++++++++ config/shortcodes.php | 43 ++++++ lone-embed.php | 77 ++++++++++ themes/neon/assets/style.css | 19 +++ 7 files changed, 426 insertions(+) create mode 100644 app/Shortcodes.php create mode 100644 config/shortcodes.php create mode 100644 lone-embed.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 1718bef..bc3b8db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to Ty Clifford's Content Management System are documented here. +## 2026-07-05 + +### Added + +- Added a declared shortcode parser with PHP-array/JSON configuration, including `[youtube=]` and `[video poster= file=]`. +- Added `lone-embed.php` as the generated HTML5 video player target for video shortcodes. + ## 2026-07-04 ### Added diff --git a/README.md b/README.md index fceca8e..635ad35 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,15 @@ Posts and static pages are Markdown `.txt` files with front matter. Comments are Post and page bodies are treated as trusted author content. Markdown is rendered while allowing raw HTML, iframes, JavaScript, and executable PHP blocks inside the `.txt` files. +Shortcodes are declared in `config/shortcodes.php` as a PHP array, with optional JSON overrides in `config/shortcodes.json`. Built-in examples: + +```text +[youtube=dQw4w9WgXcQ] +[video poster=poster.jpg file=file.mp4] +``` + +`[video]` renders an iframe pointed at `lone-embed.php`, which serves a full-frame HTML5 video player. Bare filenames resolve from `bl-content/uploads`. + ## Requirements - PHP 8.1 or newer diff --git a/app/Markdown.php b/app/Markdown.php index 58eae96..994c0c7 100644 --- a/app/Markdown.php +++ b/app/Markdown.php @@ -11,6 +11,7 @@ final class Markdown if ($executePhp) { $markdown = self::executePhp($markdown); } + $markdown = Shortcodes::render($markdown); $markdown = trim($markdown); if ($markdown === '') { return ''; diff --git a/app/Shortcodes.php b/app/Shortcodes.php new file mode 100644 index 0000000..c533b82 --- /dev/null +++ b/app/Shortcodes.php @@ -0,0 +1,270 @@ +>|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'); + } +} diff --git a/config/shortcodes.php b/config/shortcodes.php new file mode 100644 index 0000000..c4f81d7 --- /dev/null +++ b/config/shortcodes.php @@ -0,0 +1,43 @@ + [ + 'type' => 'iframe', + 'src' => 'https://www.youtube-nocookie.com/embed/{value}', + 'class' => 'shortcode-embed shortcode-embed--youtube', + 'required' => ['value'], + 'sanitize' => [ + 'value' => 'youtube_id', + ], + 'attributes' => [ + 'title' => 'YouTube video', + 'loading' => 'lazy', + 'allow' => 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share', + 'referrerpolicy' => 'strict-origin-when-cross-origin', + 'allowfullscreen' => true, + ], + ], + 'video' => [ + 'type' => 'iframe', + 'src' => 'lone-embed.php', + 'class' => 'shortcode-embed shortcode-embed--video', + 'required' => ['file'], + 'sanitize' => [ + 'file' => 'media_url', + 'poster' => 'media_url', + 'title' => 'text', + ], + 'query' => [ + 'file' => '{file}', + 'poster' => '{poster}', + 'title' => '{title}', + ], + 'attributes' => [ + 'title' => 'Embedded video', + 'loading' => 'lazy', + 'allow' => 'autoplay; fullscreen; picture-in-picture', + 'allowfullscreen' => true, + ], + ], +]; diff --git a/lone-embed.php b/lone-embed.php new file mode 100644 index 0000000..c20b886 --- /dev/null +++ b/lone-embed.php @@ -0,0 +1,77 @@ + 'video/mp4', + 'ogv', 'ogg' => 'video/ogg', + 'webm' => 'video/webm', + default => 'video/mp4', + }; +} + +function e(string $value): string +{ + return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); +} +?> + + + + + <?= e($title !== '' ? $title : 'Embedded video') ?> + + + + + + diff --git a/themes/neon/assets/style.css b/themes/neon/assets/style.css index 6061635..ab289cc 100644 --- a/themes/neon/assets/style.css +++ b/themes/neon/assets/style.css @@ -480,6 +480,25 @@ h2 { font-size: 0.92rem; } +.shortcode-embed { + display: block; + overflow: hidden; + width: 100%; + margin: 1.5rem 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #020403; + box-shadow: var(--shadow); + aspect-ratio: 16 / 9; +} + +.shortcode-embed iframe { + display: block; + width: 100%; + height: 100%; + border: 0; +} + .side-rail { display: grid; align-content: start;