- Shortcodes
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user