' . 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 .= '
'; $i++; continue; } // ── Encabezado ──────────────────────────────────────────── if (preg_match('/^(#{1,6})\s+(.*)$/', $linea, $m)) { $nivel = strlen($m[1]); $texto2 = trim($m[2]); $slug = self::slug($texto2); $html .= "" . self::inline($texto2) . ""; $i++; continue; } // ── Tabla ───────────────────────────────────────────────── if (strpos($linea, '|') !== false && isset($lineas[$i + 1]) && preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1])) { [$tabla, $i] = self::tabla($lineas, $i); $html .= $tabla; continue; } // ── Cita ────────────────────────────────────────────────── if (preg_match('/^>\s?(.*)$/', $linea)) { $buffer = []; while ($i < $n && preg_match('/^>\s?(.*)$/', $lineas[$i], $m2)) { $buffer[] = $m2[1]; $i++; } $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($m[3]) . '
  • '; $i++; } return [$html . "", $i]; } /** Construye una tabla a partir de la fila de encabezado. */ private static function tabla(array $lineas, int $i): array { $n = count($lineas); $celdas = fn(string $l) => array_map('trim', explode('|', trim($l, " \t|"))); $encabezado = $celdas($lineas[$i]); $alineacion = array_map(function ($c) { $c = trim($c); if (str_starts_with($c, ':') && str_ends_with($c, ':')) return 'center'; if (str_ends_with($c, ':')) return 'right'; return 'left'; }, $celdas($lineas[$i + 1])); $i += 2; $html = '
    '; foreach ($encabezado as $k => $c) { $a = $alineacion[$k] ?? 'left'; $html .= ''; } $html .= ''; while ($i < $n && trim($lineas[$i]) !== '' && strpos($lineas[$i], '|') !== false) { $fila = $celdas($lineas[$i]); $html .= ''; foreach ($encabezado as $k => $_) { $a = $alineacion[$k] ?? 'left'; $html .= ''; } $html .= ''; $i++; } return [$html . '
    ' . self::inline($c) . '
    ' . self::inline($fila[$k] ?? '') . '
    ', $i]; } /** * Formato dentro de una línea. Se escapa primero y el código en línea se * aparta con marcadores para que su contenido no reciba más formato. */ private static function inline(string $texto): string { $codigos = []; $texto = preg_replace_callback('/`([^`]+)`/', function ($m) use (&$codigos) { $codigos[] = '' . 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; } }