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.
189 lines
6.3 KiB
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;
|
|
}
|