You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/scripts/auditar-estilos.mjs

189 lines
6.3 KiB

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', '.svelte']);
const pesosPermitidos = new Set(['400', '500', '600']);
async function recorrer(directorio) {
const encontrados = [];
for (const entrada of await readdir(directorio, { withFileTypes: true })) {
const ruta = resolve(directorio, entrada.name);
if (entrada.isDirectory()) encontrados.push(...(await recorrer(ruta)));
else if (extensiones.has(extname(entrada.name))) encontrados.push(ruta);
}
return encontrados;
}
function cssDe(ruta, fuente) {
if (ruta.endsWith('.css')) return fuente;
return [...fuente.matchAll(/<style[^>]*>([\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;
}
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(/(?<![\w-])(--[\w-]+)\s*:/g)) {
declaraciones.set(coincidencia[1], documento.relativa);
}
for (const coincidencia of documento.fuente.matchAll(/var\(\s*(--[\w-]+)(\s*,)?/g)) {
usos.push({
nombre: coincidencia[1],
conReserva: Boolean(coincidencia[2]),
archivo: documento.relativa,
linea: lineaDe(documento.fuente, coincidencia.index)
});
}
/* Las declaraciones de @font-face describen archivos; el contrato se aplica a su uso. */
const fuenteSinDeclaraciones = documento.fuente.replace(/@font-face\s*\{[\s\S]*?\}/g, '');
for (const coincidencia of fuenteSinDeclaraciones.matchAll(/font-weight\s*:\s*(\d+)/g)) {
if (!pesosPermitidos.has(coincidencia[1])) {
pesosNoDisponibles.push({
valor: coincidencia[1],
archivo: documento.relativa,
linea: lineaDe(fuenteSinDeclaraciones, coincidencia.index)
});
}
}
}
const variablesSinDeclarar = usos.filter(
(uso) => !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: contarLiterales(css, /border-radius\s*:\s*([^;\r\n]+)/g),
espaciados: contar(css, /(?:padding|margin|gap)(?:-[a-z]+)?\s*:/g),
espaciadosLiterales: valores(
css,
/(?:padding|margin|gap)(?:-[a-z]+)?\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: contar(css, /#[\da-f]{3,8}\b|rgba?\(|hsla?\(|oklch\(/gi),
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 informe = {
archivos: documentos.length,
resumen,
variablesSinDeclarar,
pesosNoDisponibles,
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(`\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)) {
process.exitCode = 1;
}

Powered by TurnKey Linux.