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

250 lines
8.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', '.html', '.svelte']);
const pesosPermitidos = new Set(['400', '500', '600']);
const breakpointsPermitidos = new Set([
'30rem',
'34rem',
'42rem',
'48rem',
'60rem',
'64rem',
'75rem'
]);
const propiedadEspaciado =
'(?<![\\w-])(?:(?:padding|margin|scroll-margin)(?:-[a-z]+)?|(?:row|column)-gap|gap)';
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;
}
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(/(?<![\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: 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,
/(?<![\w-])(?:transition|animation)(?:-[a-z]+)?\s*:\s*([^;{}]+)/g
).filter((valor) => !valor.includes('var(') && !/^(?:none|initial|inherit)$/.test(valor))
.length,
sombrasLiterales: valores(css, /(?<![\w-])(?:box-shadow|text-shadow)\s*:\s*([^;{}]+)/g).filter(
(valor) => !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;
}

Powered by TurnKey Linux.