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.
250 lines
8.3 KiB
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;
|
|
}
|