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>
132 lines
5.0 KiB
PHP
132 lines
5.0 KiB
PHP
<?php
|
|
/**
|
|
* modules/soporte/DocIndex.php
|
|
* Descubre los documentos en docs/, arma el árbol de navegación y resuelve el
|
|
* control de acceso por sección.
|
|
*
|
|
* Convenciones:
|
|
* docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra
|
|
* El título sale del primer encabezado `# ` del archivo.
|
|
*/
|
|
|
|
final class DocIndex
|
|
{
|
|
/** Secciones, en orden de aparición. `admin` = solo administradores. */
|
|
public const SECCIONES = [
|
|
'manual' => ['titulo' => 'Manual de usuario', 'icono' => 'fas fa-book-reader', 'admin' => false,
|
|
'resumen' => 'Cómo usar el sistema, paso a paso, según tu rol.'],
|
|
'tecnica' => ['titulo' => 'Documentación técnica','icono' => 'fas fa-code', 'admin' => true,
|
|
'resumen' => 'Cada módulo por dentro: tablas, endpoints y dependencias.'],
|
|
'arquitectura'=> ['titulo' => 'Arquitectura', 'icono' => 'fas fa-sitemap', 'admin' => true,
|
|
'resumen' => 'Cómo está armado el sistema y por qué.'],
|
|
'operacion' => ['titulo' => 'Operación y soporte', 'icono' => 'fas fa-life-ring', 'admin' => true,
|
|
'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'],
|
|
];
|
|
|
|
public static function dir(): string
|
|
{
|
|
return __DIR__ . '/docs';
|
|
}
|
|
|
|
/** ¿El usuario actual puede ver esta sección? */
|
|
public static function puedeVer(string $seccion): bool
|
|
{
|
|
$cfg = self::SECCIONES[$seccion] ?? null;
|
|
if (!$cfg) return false;
|
|
if (!$cfg['admin']) return true;
|
|
return in_array($_SESSION['admin_user']['role'] ?? '', ['admin', 'superadmin'], true);
|
|
}
|
|
|
|
/** Secciones visibles para el usuario actual. */
|
|
public static function seccionesVisibles(): array
|
|
{
|
|
return array_filter(
|
|
self::SECCIONES,
|
|
fn($s) => self::puedeVer($s),
|
|
ARRAY_FILTER_USE_KEY
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Árbol completo: [seccion => ['titulo'=>..,'docs'=>[['slug','titulo','archivo'],...]]]
|
|
* Solo incluye lo que el usuario puede ver.
|
|
*/
|
|
public static function arbol(): array
|
|
{
|
|
$arbol = [];
|
|
foreach (self::seccionesVisibles() as $sec => $cfg) {
|
|
$ruta = self::dir() . '/' . $sec;
|
|
if (!is_dir($ruta)) continue;
|
|
|
|
$docs = [];
|
|
foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
|
|
$base = basename($archivo, '.md');
|
|
$slug = preg_replace('/^\d+-/', '', $base);
|
|
$docs[] = [
|
|
'slug' => $slug,
|
|
'titulo' => self::titulo($archivo, $slug),
|
|
'archivo' => $archivo,
|
|
'orden' => $base,
|
|
];
|
|
}
|
|
usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden']));
|
|
|
|
$arbol[$sec] = $cfg + ['docs' => $docs];
|
|
}
|
|
return $arbol;
|
|
}
|
|
|
|
/** Ruta del archivo pedido, o null si no existe o no hay acceso. */
|
|
public static function resolver(string $seccion, string $slug): ?string
|
|
{
|
|
if (!self::puedeVer($seccion)) return null;
|
|
// Evitar traversal: los slugs son [a-z0-9-]
|
|
if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null;
|
|
|
|
foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) {
|
|
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) === $slug) return $archivo;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Primer encabezado `# ` del archivo; si no hay, el slug capitalizado. */
|
|
private static function titulo(string $archivo, string $slugFallback): string
|
|
{
|
|
$fh = @fopen($archivo, 'r');
|
|
if ($fh) {
|
|
$lineas = 0;
|
|
while (($l = fgets($fh)) !== false && $lineas++ < 30) {
|
|
if (preg_match('/^#\s+(.+)$/', trim($l), $m)) { fclose($fh); return trim($m[1]); }
|
|
}
|
|
fclose($fh);
|
|
}
|
|
return ucfirst(str_replace('-', ' ', $slugFallback));
|
|
}
|
|
|
|
/**
|
|
* Índice para el buscador: un registro por documento con su texto plano.
|
|
* Solo incluye secciones visibles para el usuario actual.
|
|
*/
|
|
public static function indiceBusqueda(): array
|
|
{
|
|
$out = [];
|
|
foreach (self::arbol() as $sec => $cfg) {
|
|
foreach ($cfg['docs'] as $doc) {
|
|
$texto = @file_get_contents($doc['archivo']) ?: '';
|
|
// Aplanar: sin marcas, sin saltos, compacto
|
|
$texto = preg_replace('/```.*?```/s', ' ', $texto);
|
|
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
|
|
$texto = preg_replace('/\s+/', ' ', $texto);
|
|
$out[] = [
|
|
's' => $sec,
|
|
'u' => $doc['slug'],
|
|
't' => $doc['titulo'],
|
|
'c' => $cfg['titulo'],
|
|
'x' => mb_substr(trim($texto), 0, 4000),
|
|
];
|
|
}
|
|
}
|
|
return $out;
|
|
}
|
|
}
|