Files
whatsapp/modules/soporte/Markdown.php
Lizandro GuarnizoandClaude Opus 5 018fb13332 Módulo Soporte: visor de documentación con renderizado Markdown y buscador
Nuevo módulo `soporte` con la documentación del proyecto en cuatro secciones:
manual de usuario (visible para todos), y documentación técnica, arquitectura
y operación (solo administradores).

- Markdown.php: renderizador propio del subconjunto que usa la documentación
  (encabezados, listas anidadas, tablas, código, citas). Escapa todo el texto
  antes de aplicar formato, así que los .md no pueden inyectar HTML. Se
  prefirió un archivo auditable a incorporar una dependencia externa.
- DocIndex.php: descubre los .md, arma el árbol, resuelve acceso por sección
  y construye el índice del buscador.
- Generadores.php: expande marcadores {{modulos}}, {{endpoints}}, {{tablas}},
  {{roles}} y {{servicios}} leyendo el código y la base en cada carga, para
  que los inventarios no puedan quedar desactualizados.

Se registra en SYSTEM_MODULES y se concede a los 12 roles con permission=read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:07:06 -05:00

239 lines
9.8 KiB
PHP

<?php
/**
* modules/soporte/Markdown.php
* Renderizador Markdown → HTML para la documentación del proyecto.
*
* Cubre el subconjunto que usa la documentación: encabezados, listas
* (anidadas y numeradas), tablas, bloques de código, citas, reglas, enlaces,
* énfasis y código en línea. No pretende ser CommonMark completo — se prefirió
* un archivo propio y auditable a incorporar una dependencia externa.
*
* Todo el texto se escapa antes de aplicar formato, así que el contenido de
* los .md no puede inyectar HTML.
*/
final class Markdown
{
/** Convierte un documento Markdown completo a HTML. */
public static function render(string $texto): string
{
$lineas = preg_split('/\R/', $texto);
$html = '';
$n = count($lineas);
$i = 0;
while ($i < $n) {
$linea = $lineas[$i];
// ── Bloque de código cercado ──────────────────────────────
if (preg_match('/^```\s*([\w-]*)\s*$/', $linea, $m)) {
$lang = $m[1];
$buffer = [];
$i++;
while ($i < $n && !preg_match('/^```\s*$/', $lineas[$i])) {
$buffer[] = $lineas[$i];
$i++;
}
$i++; // cerrar
$clase = $lang ? ' class="lang-' . htmlspecialchars($lang, ENT_QUOTES) . '"' : '';
$html .= '<pre><code' . $clase . '>'
. htmlspecialchars(implode("\n", $buffer), ENT_QUOTES)
. '</code></pre>';
continue;
}
// ── Línea en blanco ───────────────────────────────────────
if (trim($linea) === '') { $i++; continue; }
// ── Regla horizontal ──────────────────────────────────────
if (preg_match('/^(-{3,}|\*{3,}|_{3,})\s*$/', $linea)) {
$html .= '<hr>';
$i++;
continue;
}
// ── Encabezado ────────────────────────────────────────────
if (preg_match('/^(#{1,6})\s+(.*)$/', $linea, $m)) {
$nivel = strlen($m[1]);
$texto2 = trim($m[2]);
$slug = self::slug($texto2);
$html .= "<h{$nivel} id=\"{$slug}\">" . self::inline($texto2) . "</h{$nivel}>";
$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 .= '<blockquote>' . self::render(implode("\n", $buffer)) . '</blockquote>';
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 .= '<p>' . self::inline(implode(' ', $buffer)) . '</p>';
}
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 . '</li>';
continue;
}
$html .= '<li>' . self::inline($m[3]) . '</li>';
$i++;
}
return [$html . "</{$tag}>", $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 = '<div class="tabla-scroll"><table><thead><tr>';
foreach ($encabezado as $k => $c) {
$a = $alineacion[$k] ?? 'left';
$html .= '<th style="text-align:' . $a . '">' . self::inline($c) . '</th>';
}
$html .= '</tr></thead><tbody>';
while ($i < $n && trim($lineas[$i]) !== '' && strpos($lineas[$i], '|') !== false) {
$fila = $celdas($lineas[$i]);
$html .= '<tr>';
foreach ($encabezado as $k => $_) {
$a = $alineacion[$k] ?? 'left';
$html .= '<td style="text-align:' . $a . '">' . self::inline($fila[$k] ?? '') . '</td>';
}
$html .= '</tr>';
$i++;
}
return [$html . '</tbody></table></div>', $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[] = '<code>' . htmlspecialchars($m[1], ENT_QUOTES) . '</code>';
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 '<a href="' . htmlspecialchars($url, ENT_QUOTES) . '"' . $ext . '>' . $m[1] . '</a>';
},
$texto
);
$texto = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $texto);
$texto = preg_replace('/(?<![\w*])\*([^*\n]+)\*(?![\w*])/', '<em>$1</em>', $texto);
$texto = preg_replace('/(?<![\w_])_([^_\n]+)_(?![\w_])/', '<em>$1</em>', $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;
}
}