- Descriptions now render Markdown or sanitized HTML via [includes/db.php](/Users/tyemeclifford/Documents/GH/mediaplayer/includes/db.php).

Public player descriptions render formatted HTML on the homepage.
Catalogue cards, embed meta tags, and social metadata use safe 
plain-text excerpts.
Add/edit admin pages now use a rich description editor that preserves 
pasted HTML styling, with a textarea fallback for no-JS.
External/YouTube-imported descriptions are sanitized before storage.
README now documents Markdown/HTML description support.
This commit is contained in:
Ty Clifford
2026-07-01 15:41:01 -04:00
parent e9369e242c
commit 6a1c545f35
11 changed files with 787 additions and 17 deletions
+420 -4
View File
@@ -595,6 +595,420 @@ function media_pick_longest_text(string $current, string $candidate): string {
return strlen($candidate) > strlen($current) ? $candidate : $current;
}
function media_description_contains_html(string $value): bool {
return (bool)preg_match('/<\/?[a-z][a-z0-9:-]*(?:\s[^>]*)?>/i', $value);
}
function media_pick_longest_description(string $current, string $candidate): string {
$candidate = trim($candidate);
if ($candidate === '') return $current;
$current_length = strlen(media_description_plain_text($current));
$candidate_length = strlen(media_description_plain_text($candidate));
return $candidate_length > $current_length ? $candidate : $current;
}
function media_prepare_description_for_storage(string $value): string {
$value = trim($value);
if ($value === '') return '';
return media_description_contains_html($value) ? media_sanitize_html($value) : $value;
}
function media_description_plain_text(string $value): string {
$value = trim($value);
if ($value === '') return '';
$html = media_description_html($value);
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
$text = preg_replace('/[ \t]+/', ' ', $text);
$text = preg_replace('/\s*\n\s*/', ' ', (string)$text);
return trim((string)$text);
}
function media_description_html(string $value): string {
$value = trim($value);
if ($value === '') return '';
$html = media_description_contains_html($value)
? $value
: media_markdown_to_html($value);
return media_sanitize_html($html);
}
function media_markdown_to_html(string $markdown): string {
$markdown = str_replace(["\r\n", "\r"], "\n", trim($markdown));
if ($markdown === '') return '';
$lines = explode("\n", $markdown);
$html = [];
$paragraph = [];
$blockquote = [];
$list_type = '';
$in_code = false;
$code_lines = [];
$flush_paragraph = function () use (&$html, &$paragraph): void {
if (!$paragraph) return;
$parts = array_map('media_markdown_inline', $paragraph);
$html[] = '<p>' . implode('<br>', $parts) . '</p>';
$paragraph = [];
};
$flush_blockquote = function () use (&$html, &$blockquote): void {
if (!$blockquote) return;
$parts = array_map('media_markdown_inline', $blockquote);
$html[] = '<blockquote><p>' . implode('<br>', $parts) . '</p></blockquote>';
$blockquote = [];
};
$close_list = function () use (&$html, &$list_type): void {
if ($list_type === '') return;
$html[] = '</' . $list_type . '>';
$list_type = '';
};
foreach ($lines as $line) {
if (preg_match('/^\s*```/', $line)) {
if ($in_code) {
$html[] = '<pre><code>' . h(implode("\n", $code_lines)) . '</code></pre>';
$code_lines = [];
$in_code = false;
} else {
$flush_paragraph();
$flush_blockquote();
$close_list();
$in_code = true;
$code_lines = [];
}
continue;
}
if ($in_code) {
$code_lines[] = $line;
continue;
}
if (trim($line) === '') {
$flush_paragraph();
$flush_blockquote();
$close_list();
continue;
}
if (preg_match('/^(#{1,6})\s+(.+)$/', $line, $match)) {
$flush_paragraph();
$flush_blockquote();
$close_list();
$level = strlen($match[1]);
$html[] = '<h' . $level . '>' . media_markdown_inline($match[2]) . '</h' . $level . '>';
continue;
}
if (preg_match('/^>\s?(.*)$/', $line, $match)) {
$flush_paragraph();
$close_list();
$blockquote[] = $match[1];
continue;
}
if (preg_match('/^\s*[-*+]\s+(.+)$/', $line, $match)) {
$flush_paragraph();
$flush_blockquote();
if ($list_type !== 'ul') {
$close_list();
$html[] = '<ul>';
$list_type = 'ul';
}
$html[] = '<li>' . media_markdown_inline($match[1]) . '</li>';
continue;
}
if (preg_match('/^\s*\d+[.)]\s+(.+)$/', $line, $match)) {
$flush_paragraph();
$flush_blockquote();
if ($list_type !== 'ol') {
$close_list();
$html[] = '<ol>';
$list_type = 'ol';
}
$html[] = '<li>' . media_markdown_inline($match[1]) . '</li>';
continue;
}
$close_list();
$flush_blockquote();
$paragraph[] = $line;
}
if ($in_code) $html[] = '<pre><code>' . h(implode("\n", $code_lines)) . '</code></pre>';
$flush_paragraph();
$flush_blockquote();
$close_list();
return implode("\n", $html);
}
function media_markdown_inline(string $text): string {
$tokens = [];
$text = preg_replace_callback('/`([^`\n]+)`/', function ($match) use (&$tokens): string {
$token = '@@MDTOKEN' . count($tokens) . '@@';
$tokens[$token] = '<code>' . h($match[1]) . '</code>';
return $token;
}, $text);
$html = h((string)$text);
$html = preg_replace_callback('/\[([^\]\n]+)\]\(([^)\s]+)(?:\s+&quot;[^&]*&quot;)?\)/', function ($match): string {
$url = html_entity_decode($match[2], ENT_QUOTES | ENT_HTML5, 'UTF-8');
return media_description_link_html($url, $match[1]);
}, $html);
$html = preg_replace('/\*\*([^*\n]+)\*\*/', '<strong>$1</strong>', (string)$html);
$html = preg_replace('/__([^_\n]+)__/', '<strong>$1</strong>', (string)$html);
$html = preg_replace('/(?<!\*)\*([^*\n]+)\*(?!\*)/', '<em>$1</em>', (string)$html);
$html = preg_replace('/(?<!_)_([^_\n]+)_(?!_)/', '<em>$1</em>', (string)$html);
$html = preg_replace('/~~([^~\n]+)~~/', '<s>$1</s>', (string)$html);
foreach ($tokens as $token => $replacement) {
$html = str_replace($token, $replacement, (string)$html);
}
return media_autolink_html_text((string)$html);
}
function media_autolink_html_text(string $html): string {
$parts = preg_split('/(<a\b[^>]*>.*?<\/a>|<code\b[^>]*>.*?<\/code>|<[^>]+>)/is', $html, -1, PREG_SPLIT_DELIM_CAPTURE);
if (!is_array($parts)) return $html;
foreach ($parts as $i => $part) {
if ($part === '' || str_starts_with($part, '<')) continue;
$parts[$i] = preg_replace_callback('~https?://[^\s<]+~i', function ($match): string {
$url = $match[0];
$trail = '';
while ($url !== '' && preg_match('/[.,;:!?)]$/', $url)) {
$trail = substr($url, -1) . $trail;
$url = substr($url, 0, -1);
}
return media_description_link_html($url, h($url)) . $trail;
}, $part);
}
return implode('', $parts);
}
function media_description_link_html(string $url, string $label_html): string {
$href = media_clean_url_attribute($url, false);
if ($href === '') return $label_html;
return '<a href="' . h($href) . '" target="_blank" rel="noopener noreferrer">' . $label_html . '</a>';
}
function media_clean_url_attribute(string $url, bool $for_src): string {
$url = trim(html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'));
$url = preg_replace('/[\x00-\x1F\x7F\s]+/', '', $url);
$url = (string)$url;
if ($url === '') return '';
if (str_starts_with($url, '//')) return 'https:' . $url;
if (str_starts_with($url, '#')) return $for_src ? '' : $url;
if (str_starts_with($url, '/')) return $url;
if (preg_match('/^[a-z][a-z0-9+.-]*:/i', $url, $match)) {
$scheme = strtolower(rtrim($match[0], ':'));
$allowed = $for_src ? ['http', 'https'] : ['http', 'https', 'mailto', 'tel'];
return in_array($scheme, $allowed, true) ? $url : '';
}
return preg_match('/^[^<>"\']+$/', $url) ? $url : '';
}
function media_clean_style_attribute(string $style): string {
$allowed = [
'background-color' => true,
'color' => true,
'font-size' => true,
'font-style' => true,
'font-weight' => true,
'letter-spacing' => true,
'line-height' => true,
'text-align' => true,
'text-decoration' => true,
'text-transform' => true,
];
$clean = [];
foreach (explode(';', $style) as $rule) {
if (!str_contains($rule, ':')) continue;
[$property, $value] = array_map('trim', explode(':', $rule, 2));
$property = strtolower($property);
if (!isset($allowed[$property]) || $value === '') continue;
if (preg_match('/url\s*\(|expression\s*\(|javascript:|data:|@|[<>\\\\]/i', $value)) continue;
if (strlen($value) > 160) continue;
$clean[] = $property . ': ' . $value;
}
return implode('; ', $clean);
}
function media_description_allowed_tags(): array {
return [
'a' => true, 'b' => true, 'blockquote' => true, 'br' => true,
'code' => true, 'div' => true, 'em' => true, 'h1' => true,
'h2' => true, 'h3' => true, 'h4' => true, 'h5' => true,
'h6' => true, 'i' => true, 'img' => true, 'li' => true,
'ol' => true, 'p' => true, 'pre' => true, 's' => true,
'span' => true, 'strong' => true, 'u' => true, 'ul' => true,
];
}
function media_description_drop_tags(): array {
return [
'audio' => true, 'base' => true, 'button' => true, 'canvas' => true,
'embed' => true, 'form' => true, 'iframe' => true, 'input' => true,
'link' => true, 'math' => true, 'meta' => true, 'object' => true,
'picture' => true, 'script' => true, 'select' => true, 'source' => true,
'style' => true, 'svg' => true, 'textarea' => true, 'track' => true,
'video' => true,
];
}
function media_description_allowed_attrs(string $tag): array {
$attrs = ['title' => true, 'style' => true];
if ($tag === 'a') {
$attrs['href'] = true;
$attrs['rel'] = true;
$attrs['target'] = true;
}
if ($tag === 'img') {
$attrs['src'] = true;
$attrs['alt'] = true;
$attrs['width'] = true;
$attrs['height'] = true;
$attrs['loading'] = true;
$attrs['decoding'] = true;
}
if ($tag === 'ol') $attrs['start'] = true;
return $attrs;
}
function media_sanitize_html(string $html): string {
$html = trim($html);
if ($html === '') return '';
if (!class_exists('DOMDocument')) {
return nl2br(h(html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8')));
}
$doc = new DOMDocument('1.0', 'UTF-8');
$previous = function_exists('libxml_use_internal_errors') ? libxml_use_internal_errors(true) : null;
$options = 0;
if (defined('LIBXML_HTML_NOIMPLIED')) $options |= LIBXML_HTML_NOIMPLIED;
if (defined('LIBXML_HTML_NODEFDTD')) $options |= LIBXML_HTML_NODEFDTD;
$loaded = $doc->loadHTML(
'<?xml encoding="UTF-8"><div id="media-description-root">' . $html . '</div>',
$options
);
if (function_exists('libxml_clear_errors')) libxml_clear_errors();
if ($previous !== null && function_exists('libxml_use_internal_errors')) libxml_use_internal_errors($previous);
if (!$loaded) return '';
$root = $doc->getElementById('media-description-root');
if (!$root) return '';
media_sanitize_dom_node($root, $doc);
$out = '';
foreach ($root->childNodes as $child) {
$out .= $doc->saveHTML($child);
}
return trim($out);
}
function media_sanitize_dom_node($node, $doc): void {
$allowed_tags = media_description_allowed_tags();
$drop_tags = media_description_drop_tags();
for ($child = $node->firstChild; $child !== null; $child = $next) {
$next = $child->nextSibling;
if ($child->nodeType === 8) {
$node->removeChild($child);
continue;
}
if ($child->nodeType !== 1) continue;
$tag = strtolower($child->nodeName);
if (isset($drop_tags[$tag])) {
$node->removeChild($child);
continue;
}
media_sanitize_dom_node($child, $doc);
if (!isset($allowed_tags[$tag])) {
while ($child->firstChild) {
$node->insertBefore($child->firstChild, $child);
}
$node->removeChild($child);
continue;
}
media_sanitize_dom_attributes($child, $tag);
if ($tag === 'img' && !$child->hasAttribute('src')) {
$node->removeChild($child);
}
}
}
function media_sanitize_dom_attributes($element, string $tag): void {
$allowed = media_description_allowed_attrs($tag);
$attrs = [];
if ($element->hasAttributes()) {
foreach ($element->attributes as $attr) $attrs[] = $attr;
}
foreach ($attrs as $attr) {
$name = strtolower($attr->nodeName);
$value = (string)$attr->nodeValue;
if (str_starts_with($name, 'on') || !isset($allowed[$name])) {
$element->removeAttribute($attr->nodeName);
continue;
}
if ($name === 'style') {
$clean = media_clean_style_attribute($value);
if ($clean === '') $element->removeAttribute($attr->nodeName);
else $element->setAttribute('style', $clean);
continue;
}
if ($name === 'href' || $name === 'src') {
$clean = media_clean_url_attribute($value, $name === 'src');
if ($clean === '') $element->removeAttribute($attr->nodeName);
else $element->setAttribute($name, $clean);
continue;
}
if (in_array($name, ['width', 'height', 'start'], true)) {
$number = max(0, min(4000, (int)$value));
if ($number <= 0) $element->removeAttribute($attr->nodeName);
else $element->setAttribute($name, (string)$number);
continue;
}
}
if ($tag === 'a') {
if ($element->hasAttribute('href')) {
$element->setAttribute('target', '_blank');
$element->setAttribute('rel', 'noopener noreferrer');
} else {
$element->removeAttribute('target');
$element->removeAttribute('rel');
}
}
if ($tag === 'img') {
if (!$element->hasAttribute('src')) return;
$element->setAttribute('loading', 'lazy');
$element->setAttribute('decoding', 'async');
}
}
function media_view_count_from_html(string $html): int {
$html = html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
$variants = [$html, stripcslashes($html)];
@@ -984,14 +1398,16 @@ function media_apply_metadata_value(array &$metadata, string $key, $value): void
return;
}
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($text === '') return;
if ($key === 'description') {
$metadata['description'] = media_pick_longest_text((string)$metadata['description'], $text);
$description = media_prepare_description_for_storage((string)$value);
if ($description === '') return;
$metadata['description'] = media_pick_longest_description((string)$metadata['description'], $description);
return;
}
$text = trim(html_entity_decode(strip_tags((string)$value), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
if ($text === '') return;
if ((string)$metadata[$key] === '') $metadata[$key] = $text;
}