fix(description): render leaked markdown headers instead of showing raw

AI-generated descriptions ("Letra" / "Chorus" / "Translation") landed
in the DB with markdown prefixes that the sanitiser treated as plain
text — so viewers saw literal "## Letra" and "### Chorus" instead of
headings.

HtmlSanitizer::render() now promotes `##` / `###` at the start of a
line into <h2>/<h3> before sanitising, so the existing allowlist
accepts them and the reader sees real headings. Only touches lines
whose entire content is a heading — markdown mid-sentence is left
alone to avoid false positives.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 10:21:17 +03:00
1 parent 9eb9535c83
commit 36295fcd6a
1 file changed
+25
+25
View File
@@ -77,6 +77,11 @@ class HtmlSanitizer
/** /**
* Produce safe display HTML. Legacy plain-text values (no tags) are * Produce safe display HTML. Legacy plain-text values (no tags) are
* escaped and converted to <br>; rich values are run through clean(). * escaped and converted to <br>; rich values are run through clean().
*
* Also detects a handful of common markdown-ish prefixes that leaked
* in through AI-generated descriptions ("## Letra", "### Chorus") and
* turns them into real <h2>/<h3> tags before sanitising. Without this
* step a reader saw literal "## Letra" instead of a heading.
*/ */
public static function render(?string $value): string public static function render(?string $value): string
{ {
@@ -85,6 +90,8 @@ class HtmlSanitizer
return ''; return '';
} }
$value = self::promoteLeakedMarkdown($value);
if (strip_tags($value) === $value) { if (strip_tags($value) === $value) {
return nl2br(e($value), false); return nl2br(e($value), false);
} }
@@ -92,6 +99,24 @@ class HtmlSanitizer
return self::clean($value); return self::clean($value);
} }
/**
* Convert markdown-style heading prefixes at the start of a line into
* real HTML tags, so the sanitiser can keep them. Only touches lines
* that start with ##, ###, or **…**; everything else is untouched.
*/
private static function promoteLeakedMarkdown(string $value): string
{
$lines = preg_split("/\r?\n/", $value);
foreach ($lines as $i => $line) {
if (preg_match('/^\s*###\s+(.+?)\s*$/u', $line, $m)) {
$lines[$i] = '<h3>' . e($m[1]) . '</h3>';
} elseif (preg_match('/^\s*##\s+(.+?)\s*$/u', $line, $m)) {
$lines[$i] = '<h2>' . e($m[1]) . '</h2>';
}
}
return implode("\n", $lines);
}
private static function sanitizeChildren(DOMNode $node): void private static function sanitizeChildren(DOMNode $node): void
{ {
foreach (iterator_to_array($node->childNodes) as $child) { foreach (iterator_to_array($node->childNodes) as $child) {