'
. htmlspecialchars(implode("\n", $buffer), ENT_QUOTES)
. '';
continue;
}
// ── Línea en blanco ───────────────────────────────────────
if (trim($linea) === '') { $i++; continue; }
// ── Regla horizontal ──────────────────────────────────────
if (preg_match('/^(-{3,}|\*{3,}|_{3,})\s*$/', $linea)) {
$html .= '' . self::render(implode("\n", $buffer)) . ''; continue; } // ── Lista (con o sin numerar, admite anidación) ─────────── if (preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $linea)) { [$lista, $i] = self::lista($lineas, $i, 0); $html .= $lista; continue; } // ── Párrafo ─────────────────────────────────────────────── $buffer = []; while ($i < $n && trim($lineas[$i]) !== '' && !preg_match('/^(#{1,6}\s|```|>|\s*([-*+]|\d+\.)\s|(-{3,}|\*{3,}|_{3,})\s*$)/', $lineas[$i]) && !(strpos($lineas[$i], '|') !== false && isset($lineas[$i + 1]) && preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1]))) { $buffer[] = $lineas[$i]; $i++; } if ($buffer) $html .= '
' . self::inline(implode(' ', $buffer)) . '
'; } return $html; } /** Construye una lista, recursivamente para los niveles anidados. */ private static function lista(array $lineas, int $i, int $sangriaBase): array { $n = count($lineas); preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i], $m0); $ordenada = !in_array($m0[2], ['-', '*', '+'], true); $tag = $ordenada ? 'ol' : 'ul'; $html = "<{$tag}>"; while ($i < $n) { if (trim($lineas[$i]) === '') { // Una línea vacía corta la lista salvo que siga otro ítem if (isset($lineas[$i + 1]) && preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i + 1])) { $i++; continue; } break; } if (!preg_match('/^(\s*)([-*+]|\d+\.)\s+(.*)$/', $lineas[$i], $m)) break; $sangria = strlen($m[1]); if ($sangria < $sangriaBase) break; if ($sangria > $sangriaBase) { [$sub, $i] = self::lista($lineas, $i, $sangria); // Colgar la sublista del último ítem abierto $html = preg_replace('/<\/li>$/', '', $html) . $sub . ''; continue; } $html .= '| ' . self::inline($c) . ' | '; } $html .= '
|---|
| ' . self::inline($fila[$k] ?? '') . ' | '; } $html .= '
' . htmlspecialchars($m[1], ENT_QUOTES) . '';
return "\x00" . (count($codigos) - 1) . "\x00";
}, $texto);
$texto = htmlspecialchars($texto, ENT_QUOTES);
// Enlaces [texto](destino) — solo http(s), rutas internas y anclas
$texto = preg_replace_callback(
'/\[([^\]]+)\]\(([^)\s]+)\)/',
function ($m) {
$url = html_entity_decode($m[2], ENT_QUOTES);
if (!preg_match('#^(https?://|/|\?|\#)#', $url)) return $m[0];
$ext = str_starts_with($url, 'http') ? ' target="_blank" rel="noopener"' : '';
return '' . $m[1] . '';
},
$texto
);
$texto = preg_replace('/\*\*([^*]+)\*\*/', '$1', $texto);
$texto = preg_replace('/(?$1', $texto);
$texto = preg_replace('/(?$1', $texto);
// Restaurar código en línea
return preg_replace_callback('/\x00(\d+)\x00/', fn($m) => $codigos[(int)$m[1]] ?? '', $texto);
}
/** Ancla estable para un encabezado. */
public static function slug(string $texto): string
{
$t = strtr(mb_strtolower(strip_tags($texto)), [
'á'=>'a','é'=>'e','í'=>'i','ó'=>'o','ú'=>'u','ñ'=>'n','ü'=>'u',
]);
$t = preg_replace('/[^a-z0-9]+/', '-', $t);
return trim($t, '-');
}
/** Extrae los encabezados h2/h3 para la tabla de contenidos lateral. */
public static function indice(string $texto): array
{
$out = [];
foreach (preg_split('/\R/', $texto) as $l) {
if (preg_match('/^(#{2,3})\s+(.*)$/', $l, $m)) {
$t = trim($m[2]);
$out[] = ['nivel' => strlen($m[1]), 'texto' => $t, 'slug' => self::slug($t)];
}
}
return $out;
}
}