Files
whatsapp/modules/soporte/Generadores.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

221 lines
8.9 KiB
PHP

<?php
/**
* modules/soporte/Generadores.php
* Secciones de la documentación que se leen del código y de la base de datos
* en cada carga, en vez de escribirse a mano.
*
* El objetivo es que los inventarios (módulos, endpoints, tablas, roles) no
* puedan quedar desactualizados: si alguien agrega un módulo o una tabla,
* aparece aquí sin que nadie tenga que acordarse de editar un .md.
*
* Se invocan desde los documentos con marcadores en una línea propia:
* {{modulos}} {{endpoints}} {{tablas}} {{roles}} {{servicios}}
*/
final class Generadores
{
/** Reemplaza los marcadores {{...}} de un documento por su tabla generada. */
public static function expandir(string $md): string
{
return preg_replace_callback('/^\{\{(\w+)\}\}\s*$/m', function ($m) {
$metodo = 'gen' . ucfirst($m[1]);
if (!method_exists(self::class, $metodo)) return $m[0];
try {
return self::$metodo();
} catch (\Throwable $e) {
return '> No se pudo generar esta sección: ' . $e->getMessage();
}
}, $md);
}
private static function pdo(): PDO
{
return Database::getInstance()->getConnection();
}
private static function raiz(): string
{
return dirname(__DIR__, 2);
}
// ── Módulos registrados ───────────────────────────────────────────
private static function genModulos(): string
{
$dirs = glob(self::raiz() . '/modules/*', GLOB_ONLYDIR) ?: [];
$filas = [];
foreach ($dirs as $dir) {
$slug = basename($dir);
$meta = ['name' => $slug, 'description' => '', 'oleada' => ''];
$mf = $dir . '/module.php';
if (is_file($mf)) {
try {
$m = @include $mf;
if (is_array($m)) $meta = $m + $meta;
} catch (\Throwable $e) { /* un module.php con contexto no cargable no debe romper la doc */ }
}
$vistas = count(glob($dir . '/views/*.php') ?: []);
$apis = count(glob($dir . '/api/*.php') ?: []);
$enSys = defined('SYSTEM_MODULES') && array_key_exists($slug, SYSTEM_MODULES);
$filas[] = [
'`' . $slug . '`',
$meta['name'] ?? $slug,
$vistas ?: '—',
$apis ?: '—',
$enSys ? 'sí' : 'no',
$meta['description'] ?? '',
];
}
usort($filas, fn($a, $b) => strcmp($a[0], $b[0]));
return self::tabla(
['Slug', 'Nombre', 'Vistas', 'Endpoints', 'En SYSTEM_MODULES', 'Descripción'],
$filas
) . "\n\n_" . count($filas) . " módulos en `modules/`. Generado del filesystem._\n";
}
// ── Endpoints por módulo ──────────────────────────────────────────
private static function genEndpoints(): string
{
$out = '';
foreach (glob(self::raiz() . '/modules/*/api', GLOB_ONLYDIR) ?: [] as $dir) {
$slug = basename(dirname($dir));
$files = glob($dir . '/*.php') ?: [];
if (!$files) continue;
sort($files);
$filas = [];
foreach ($files as $f) {
$nombre = basename($f);
if (str_starts_with($nombre, '_')) continue; // helpers internos
$filas[] = ['`' . $nombre . '`', self::resumenPhpDoc($f)];
}
if (!$filas) continue;
$out .= "\n### " . $slug . "\n\n" . self::tabla(['Endpoint', 'Qué hace'], $filas) . "\n";
}
return $out ?: '> Sin endpoints.';
}
// ── Tablas de la base de datos ────────────────────────────────────
private static function genTablas(): string
{
$rows = self::pdo()->query(
"SELECT table_name, table_rows, table_comment
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
ORDER BY table_name"
)->fetchAll(PDO::FETCH_ASSOC);
// Agrupar por prefijo para que se lea por dominio
$grupos = [];
foreach ($rows as $r) {
$t = $r['table_name'];
$pfx = str_contains($t, '_') ? explode('_', $t)[0] : 'otras';
$grupos[$pfx][] = $r;
}
ksort($grupos);
$out = '';
foreach ($grupos as $pfx => $tablas) {
$out .= "\n### " . $pfx . "\n\n";
$filas = [];
foreach ($tablas as $t) {
$cols = self::pdo()->prepare(
"SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = ?"
);
$cols->execute([$t['table_name']]);
$filas[] = [
'`' . $t['table_name'] . '`',
(string)(int)$cols->fetchColumn(),
number_format((int)$t['table_rows'], 0, ',', '.'),
$t['table_comment'] ?: '',
];
}
$out .= self::tabla(['Tabla', 'Columnas', 'Filas aprox.', 'Comentario'], $filas) . "\n";
}
return $out . "\n_" . count($rows) . " tablas. Generado de `information_schema`; el conteo de filas es una estimación de InnoDB._\n";
}
// ── Roles y permisos efectivos ────────────────────────────────────
private static function genRoles(): string
{
$roles = self::pdo()->query(
"SELECT r.id, r.slug, r.name, r.description,
(SELECT COUNT(*) FROM admin_users u WHERE u.role_id = r.id AND u.is_active = 1) AS usuarios
FROM roles r ORDER BY r.slug"
)->fetchAll(PDO::FETCH_ASSOC);
$mods = self::pdo()->query(
"SELECT role_id, module_slug, permission FROM role_modules ORDER BY module_slug"
)->fetchAll(PDO::FETCH_ASSOC);
$porRol = [];
foreach ($mods as $m) {
$porRol[(int)$m['role_id']][] = $m['module_slug'] . ($m['permission'] === 'read' ? ' _(solo lectura)_' : '');
}
$filas = [];
foreach ($roles as $r) {
$lista = $porRol[(int)$r['id']] ?? [];
$filas[] = [
'`' . $r['slug'] . '`',
$r['name'] ?: '',
(string)(int)$r['usuarios'],
$lista ? implode(', ', $lista) : '—',
];
}
return self::tabla(['Rol', 'Nombre', 'Usuarios activos', 'Módulos'], $filas)
. "\n\n_Generado de `roles` y `role_modules`. El acceso efectivo se carga al iniciar sesión desde `role_id`._\n";
}
// ── Servicios y clases del núcleo ─────────────────────────────────
private static function genServicios(): string
{
$out = '';
foreach ([['core', 'Núcleo'], ['services', 'Servicios'], ['classes', 'Clases de dominio']] as [$dir, $tit]) {
$files = glob(self::raiz() . '/' . $dir . '/*.php') ?: [];
if (!$files) continue;
sort($files);
$filas = [];
foreach ($files as $f) {
$filas[] = ['`' . basename($f) . '`', self::resumenPhpDoc($f)];
}
$out .= "\n### " . $tit . " (`" . $dir . "/`)\n\n" . self::tabla(['Archivo', 'Responsabilidad'], $filas) . "\n";
}
return $out;
}
/** Primera línea con contenido del bloque docblock inicial de un archivo. */
private static function resumenPhpDoc(string $archivo): string
{
$fh = @fopen($archivo, 'r');
if (!$fh) return '';
$n = 0;
$ruta = null;
while (($l = fgets($fh)) !== false && $n++ < 25) {
$l = trim($l);
if (!str_starts_with($l, '*')) continue;
$l = trim(ltrim($l, '*/ '));
if ($l === '') continue;
// Saltar la línea que solo repite la ruta del archivo
if ($ruta === null && str_contains($l, '.php')) { $ruta = $l; continue; }
if (str_starts_with($l, '@')) break;
fclose($fh);
return $l;
}
fclose($fh);
return '';
}
/** Arma una tabla Markdown escapando los separadores del contenido. */
private static function tabla(array $encabezados, array $filas): string
{
$esc = fn($v) => str_replace('|', '\\|', (string)$v);
$md = '| ' . implode(' | ', array_map($esc, $encabezados)) . " |\n";
$md .= '|' . str_repeat('---|', count($encabezados)) . "\n";
foreach ($filas as $f) {
$md .= '| ' . implode(' | ', array_map($esc, $f)) . " |\n";
}
return $md;
}
}