import { createHash } from 'node:crypto'; import { readdir, readFile } from 'node:fs/promises'; import { extname, relative, resolve } from 'node:path'; const RAIZ = resolve(import.meta.dirname, '..'); const SRC = resolve(RAIZ, 'src'); const JSON_SALIDA = process.argv.includes('--json'); const ESTRICTO = process.argv.includes('--strict'); const extensiones = new Set(['.css', '.html', '.svelte']); const pesosPermitidos = new Set(['400', '500', '600']); const breakpointsPermitidos = new Set([ '30rem', '34rem', '42rem', '48rem', '60rem', '64rem', '75rem' ]); const propiedadEspaciado = '(?]*>([\s\S]*?)<\/style>/g)] .map((coincidencia) => coincidencia[1]) .join('\n'); } function lineaDe(texto, indice) { return texto.slice(0, indice).split('\n').length; } function ambitoDe(ruta) { const normalizada = ruta.replaceAll('\\', '/'); if (normalizada.includes('/routes/(sitio)/')) return 'sitio'; if (normalizada.includes('/lib/components/')) return 'componentes'; if (normalizada.includes('/panel/')) return 'panel'; if (normalizada.includes('/estudio/')) return 'estudio'; return 'global'; } function contar(css, expresion) { return [...css.matchAll(expresion)].length; } function valores(css, expresion) { return [...css.matchAll(expresion)].map((coincidencia) => coincidencia[1].trim()); } function contarLiterales(css, expresion) { return valores(css, expresion).filter((valor) => !valor.includes('var(')).length; } function contarColoresLiterales(css) { return [...css.matchAll(/(?:^|[;{])\s*(?:--[\w-]+|[\w-]+)\s*:\s*([^;{}]+)/gm)].reduce( (total, coincidencia) => { const valor = coincidencia[1]; if (valor.includes('var(')) return total; return total + contar(valor, /#[\da-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/gi); }, 0 ); } const rutas = await recorrer(SRC); const documentos = await Promise.all( rutas.map(async (ruta) => { const fuente = await readFile(ruta, 'utf8'); return { ruta, relativa: relative(RAIZ, ruta).replaceAll('\\', '/'), fuente, css: cssDe(ruta, fuente), ambito: ambitoDe(ruta) }; }) ); const declaraciones = new Map(); const usos = []; const pesosNoDisponibles = []; for (const documento of documentos) { /* También se declaran custom properties desde atributos `style` de Svelte. */ for (const coincidencia of documento.fuente.matchAll(/(? !uso.conReserva && !declaraciones.has(uso.nombre) ); const ambitos = ['sitio', 'componentes', 'panel', 'estudio', 'global']; const resumen = ambitos.map((ambito) => { const delAmbito = documentos.filter((documento) => documento.ambito === ambito); const css = delAmbito.map((documento) => documento.css).join('\n'); return { ambito, archivos: delAmbito.length, bloques: contar(css, /(?:^|\n)\s*[^@/][^{]*\{/g), tamanosTipograficos: contar(css, /font-size\s*:/g), tamanosTipograficosLiterales: contarLiterales(css, /font-size\s*:\s*([^;\r\n]+)/g), radios: contar(css, /border-radius\s*:/g), radiosLiterales: valores(css, /border-radius\s*:\s*([^;\r\n]+)/g).filter( (valor) => !valor.includes('var(') && !/^(?:0|none|inherit)$/.test(valor) ).length, espaciados: contar(css, new RegExp(`${propiedadEspaciado}\\s*:`, 'g')), espaciadosLiterales: valores( css, new RegExp(`${propiedadEspaciado}\\s*:\\s*([^;\\r\\n]+)`, 'g') ).filter((valor) => !valor.includes('var(') && !/^(?:0|auto|none)$/.test(valor)).length, bordes: contar(css, /border(?:-[a-z]+)?\s*:/g), coloresLiterales: contarColoresLiterales(css), movimientosLiterales: valores( css, /(? !valor.includes('var(') && !/^(?:none|initial|inherit)$/.test(valor)) .length, sombrasLiterales: valores(css, /(? !valor.includes('var(') && !/^(?:none|initial|inherit)$/.test(valor) ).length, mediaQueries: contar(css, /@media\s*\(/g), important: contar(css, /!important/g) }; }); const breakpoints = new Map(); for (const documento of documentos) { for (const coincidencia of documento.css.matchAll( /@media\s*\([^)]*(?:min|max)-width\s*:\s*([^\s)]+)/g )) { breakpoints.set(coincidencia[1], (breakpoints.get(coincidencia[1]) ?? 0) + 1); } } const fuentes = []; const directorioFuentes = resolve(RAIZ, 'static', 'fuentes'); const nombresFuentes = (await readdir(directorioFuentes)) .filter((nombre) => nombre.endsWith('.woff2')) .sort(); for (const nombre of nombresFuentes) { const ruta = resolve(directorioFuentes, nombre); const contenido = await readFile(ruta); fuentes.push({ nombre, sha256: createHash('sha256').update(contenido).digest('hex') }); } const infraccionesContrato = []; for (const fila of resumen) { for (const [metrica, etiqueta] of [ ['tamanosTipograficosLiterales', 'tamaños tipográficos literales'], ['radiosLiterales', 'radios literales'], ['espaciadosLiterales', 'espaciados literales'], ['movimientosLiterales', 'movimientos literales'], ['sombrasLiterales', 'sombras literales'], ['important', 'usos de !important'] ]) { if (fila[metrica] > 0) infraccionesContrato.push(`${fila.ambito}: ${fila[metrica]} ${etiqueta}`); } if (fila.ambito !== 'global' && fila.coloresLiterales > 0) infraccionesContrato.push(`${fila.ambito}: ${fila.coloresLiterales} colores literales`); } for (const [valor] of breakpoints) { if (!breakpointsPermitidos.has(valor)) infraccionesContrato.push(`breakpoint fuera de la escala compartida: ${valor}`); } const informe = { archivos: documentos.length, resumen, variablesSinDeclarar, pesosNoDisponibles, infraccionesContrato, breakpoints: [...breakpoints.entries()] .map(([valor, veces]) => ({ valor, veces })) .sort((a, b) => b.veces - a.veces || a.valor.localeCompare(b.valor)), fuentes }; if (JSON_SALIDA) { console.log(JSON.stringify(informe, null, 2)); } else { console.log('Auditoría de estilos de Senza Paura\n'); console.table(resumen); console.log(`Variables CSS sin declarar y sin reserva: ${variablesSinDeclarar.length}`); for (const uso of variablesSinDeclarar) { console.log(` ${uso.nombre} — ${uso.archivo}:${uso.linea}`); } console.log(`\nPesos tipográficos no disponibles: ${pesosNoDisponibles.length}`); for (const peso of pesosNoDisponibles) { console.log(` ${peso.valor} — ${peso.archivo}:${peso.linea}`); } console.log(`\nInfracciones del contrato visual: ${infraccionesContrato.length}`); for (const infraccion of infraccionesContrato) console.log(` ${infraccion}`); console.log(`\nBreakpoints distintos: ${breakpoints.size}`); console.log( [...breakpoints.entries()] .sort((a, b) => b[1] - a[1]) .map(([valor, veces]) => `${valor} (${veces})`) .join(' · ') ); const hashes = new Set(fuentes.map((fuente) => fuente.sha256)); console.log( `\nFuentes auditadas: ${fuentes.length} archivos, ${hashes.size} binarios distintos.` ); } if ( ESTRICTO && (variablesSinDeclarar.length > 0 || pesosNoDisponibles.length > 0 || infraccionesContrato.length > 0) ) { process.exitCode = 1; }