- Shortcodes

This commit is contained in:
Ty Clifford
2026-07-04 21:53:18 -04:00
parent bb93bf58fe
commit 9dbd29d8aa
7 changed files with 426 additions and 0 deletions
+7
View File
@@ -2,6 +2,13 @@
All notable changes to Ty Clifford's Content Management System are documented here. 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=<id>]` and `[video poster=<poster> file=<file>]`.
- Added `lone-embed.php` as the generated HTML5 video player target for video shortcodes.
## 2026-07-04 ## 2026-07-04
### Added ### Added
+9
View File
@@ -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. 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 ## Requirements
- PHP 8.1 or newer - PHP 8.1 or newer
+1
View File
@@ -11,6 +11,7 @@ final class Markdown
if ($executePhp) { if ($executePhp) {
$markdown = self::executePhp($markdown); $markdown = self::executePhp($markdown);
} }
$markdown = Shortcodes::render($markdown);
$markdown = trim($markdown); $markdown = trim($markdown);
if ($markdown === '') { if ($markdown === '') {
return ''; return '';
+270
View File
@@ -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');
}
}
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
return [
'youtube' => [
'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,
],
],
];
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
$file = media_url((string) ($_GET['file'] ?? ''));
$poster = media_url((string) ($_GET['poster'] ?? ''));
$title = trim((string) ($_GET['title'] ?? 'Embedded video'));
if ($file === '') {
http_response_code(400);
echo 'Missing video file.';
exit;
}
function media_url(string $value): string
{
$value = trim($value);
if ($value === '' || preg_match('/[\x00-\x1F\x7F]/', $value) || str_contains($value, '..') || preg_match('/^\s*(javascript|data):/i', $value)) {
return '';
}
if (preg_match('#^https?://#i', $value) || str_starts_with($value, '/')) {
return $value;
}
return str_contains($value, '/') ? $value : 'bl-content/uploads/' . $value;
}
function media_type(string $value): string
{
$extension = strtolower(pathinfo((string) parse_url($value, PHP_URL_PATH), PATHINFO_EXTENSION));
return match ($extension) {
'm4v', 'mp4' => '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');
}
?><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= e($title !== '' ? $title : 'Embedded video') ?></title>
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
background: #05070b;
color: #e8fff4;
}
body {
display: grid;
place-items: center;
}
video {
width: 100%;
height: 100%;
max-height: 100vh;
background: #000;
object-fit: contain;
}
</style>
</head>
<body>
<video controls playsinline preload="metadata"<?php if ($poster !== ''): ?> poster="<?= e($poster) ?>"<?php endif; ?>>
<source src="<?= e($file) ?>" type="<?= e(media_type($file)) ?>">
</video>
</body>
</html>
+19
View File
@@ -480,6 +480,25 @@ h2 {
font-size: 0.92rem; 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 { .side-rail {
display: grid; display: grid;
align-content: start; align-content: start;