parent
a9524fe411
commit
65da577c05
@ -0,0 +1,188 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,250 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { EstiloConObra } from '$lib/types';
|
||||
|
||||
type OpcionesCatalogo = {
|
||||
vista?: string;
|
||||
estilo?: string;
|
||||
etiqueta?: string;
|
||||
idioma?: string;
|
||||
};
|
||||
|
||||
type IdiomaConTotal = {
|
||||
codigo: string;
|
||||
nombre: string;
|
||||
total: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
prefijo: string;
|
||||
bloques: EstiloConObra[];
|
||||
idiomas: IdiomaConTotal[];
|
||||
estiloActual: string;
|
||||
etiquetaActual: string;
|
||||
idiomaActual: string;
|
||||
vistaActual: string;
|
||||
rutaDeCatalogo: (opciones?: OpcionesCatalogo) => '/musica' | `/musica?${string}`;
|
||||
movil?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
prefijo,
|
||||
bloques,
|
||||
idiomas,
|
||||
estiloActual,
|
||||
etiquetaActual,
|
||||
idiomaActual,
|
||||
vistaActual,
|
||||
rutaDeCatalogo,
|
||||
movil = false
|
||||
}: Props = $props();
|
||||
|
||||
const idCatalogo = $derived(`${prefijo}-catalogo`);
|
||||
const idEstilos = $derived(`${prefijo}-estilos`);
|
||||
const idIdiomas = $derived(`${prefijo}-idiomas`);
|
||||
</script>
|
||||
|
||||
<div class="filtros" class:movil>
|
||||
<nav aria-label="Ver catálogo">
|
||||
<p class="rotulo" id={idCatalogo}>Catálogo</p>
|
||||
<ul aria-labelledby={idCatalogo}>
|
||||
<li>
|
||||
<a
|
||||
href={resolve(rutaDeCatalogo())}
|
||||
aria-current={!vistaActual && !estiloActual && !etiquetaActual && !idiomaActual
|
||||
? 'page'
|
||||
: undefined}
|
||||
>
|
||||
<span>Todo el catálogo</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={resolve(
|
||||
rutaDeCatalogo({
|
||||
vista: 'albumes',
|
||||
estilo: estiloActual,
|
||||
etiqueta: etiquetaActual,
|
||||
idioma: idiomaActual
|
||||
})
|
||||
)}
|
||||
aria-current={vistaActual === 'albumes' ? 'page' : undefined}
|
||||
>
|
||||
<span>Todos los álbumes</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href={resolve(
|
||||
rutaDeCatalogo({
|
||||
vista: 'canciones',
|
||||
estilo: estiloActual,
|
||||
etiqueta: etiquetaActual,
|
||||
idioma: idiomaActual
|
||||
})
|
||||
)}
|
||||
aria-current={vistaActual === 'canciones' ? 'page' : undefined}
|
||||
>
|
||||
<span>Todas las canciones</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav aria-label="Estilos">
|
||||
<p class="rotulo" id={idEstilos}>Estilos</p>
|
||||
<ul aria-labelledby={idEstilos}>
|
||||
{#each bloques as bloque (bloque.estilo.slug)}
|
||||
<li>
|
||||
<a
|
||||
href={resolve(
|
||||
rutaDeCatalogo({
|
||||
vista: vistaActual,
|
||||
estilo: bloque.estilo.slug,
|
||||
etiqueta: etiquetaActual,
|
||||
idioma: idiomaActual
|
||||
})
|
||||
)}
|
||||
aria-current={bloque.estilo.slug === estiloActual ? 'page' : undefined}
|
||||
title="Filtrar por {bloque.estilo.nombre}: {bloque.albumes.length} {bloque.albumes
|
||||
.length === 1
|
||||
? 'lanzamiento'
|
||||
: 'lanzamientos'}"
|
||||
>
|
||||
<span>{bloque.estilo.nombre}</span>
|
||||
<span class="cuenta muted">{bloque.albumes.length}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav aria-label="Idiomas">
|
||||
<p class="rotulo" id={idIdiomas}>Idiomas</p>
|
||||
<ul class="lista-idiomas" aria-labelledby={idIdiomas}>
|
||||
<li class="todos-idiomas">
|
||||
<a
|
||||
href={resolve(
|
||||
rutaDeCatalogo({
|
||||
vista: vistaActual,
|
||||
estilo: estiloActual,
|
||||
etiqueta: etiquetaActual
|
||||
})
|
||||
)}
|
||||
aria-current={!idiomaActual ? 'page' : undefined}
|
||||
>
|
||||
<span>Todos los idiomas</span>
|
||||
</a>
|
||||
</li>
|
||||
{#each idiomas as idioma (idioma.codigo)}
|
||||
<li>
|
||||
<a
|
||||
href={resolve(
|
||||
rutaDeCatalogo({
|
||||
vista: vistaActual,
|
||||
estilo: estiloActual,
|
||||
etiqueta: etiquetaActual,
|
||||
idioma: idioma.codigo
|
||||
})
|
||||
)}
|
||||
aria-current={idioma.codigo === idiomaActual ? 'page' : undefined}
|
||||
title="{idioma.total} {idioma.total === 1 ? 'canción' : 'canciones'} en {idioma.nombre}"
|
||||
>
|
||||
<span>{idioma.nombre}</span>
|
||||
<span class="cuenta muted">{idioma.total}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.filtros {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.rotulo {
|
||||
margin: 0 0 var(--sp-2);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--c-text-muted);
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-2);
|
||||
min-height: var(--control-height-compact);
|
||||
margin-left: calc(var(--sp-3) * -1);
|
||||
padding: var(--sp-1) 0 var(--sp-1) var(--sp-3);
|
||||
border-left: 2px solid transparent;
|
||||
color: var(--c-text-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--duration-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
a:hover,
|
||||
a:focus-visible {
|
||||
color: var(--c-text);
|
||||
}
|
||||
|
||||
a[aria-current='page'] {
|
||||
border-left-color: var(--c-accent);
|
||||
color: var(--c-accent);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.cuenta {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.lista-idiomas {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: var(--sp-3);
|
||||
}
|
||||
|
||||
.todos-idiomas {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.movil {
|
||||
gap: var(--sp-7);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.movil .rotulo {
|
||||
margin-bottom: var(--sp-3);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.movil a {
|
||||
min-height: var(--control-height-touch);
|
||||
margin-left: 0;
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
}
|
||||
|
||||
.movil .lista-idiomas {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
a {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Contrato visual compartido por la web pública, el panel y Estudio.
|
||||
*
|
||||
* Estos tokens describen funciones, no páginas concretas. Un componente puede
|
||||
* elegir entre ellos, pero no debe inventar otra escala para resolver el mismo
|
||||
* papel. Los colores base y las familias pertenecen a `marca.css`; aquí viven
|
||||
* tipografía, interacción, geometría, movimiento y elevación.
|
||||
*/
|
||||
:root {
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
|
||||
--leading-heading: 1.1;
|
||||
--leading-ui: 1.35;
|
||||
--leading-body: 1.65;
|
||||
|
||||
--control-height-compact: 2.25rem;
|
||||
--control-height: 2.75rem;
|
||||
--control-height-touch: 3rem;
|
||||
--icon-size-sm: 1.25rem;
|
||||
--icon-size-md: 1.5rem;
|
||||
--icon-size-lg: 1.75rem;
|
||||
|
||||
--radius-detail: 4px;
|
||||
--radius-overlay: 12px;
|
||||
--radius-round: 999px;
|
||||
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 400ms;
|
||||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-emphasized: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
--c-overlay: rgb(3 8 20 / 72%);
|
||||
--c-scrim-base: rgb(3 8 20);
|
||||
--c-text-on-media: oklch(0.97 0.01 255);
|
||||
--c-text-on-media-muted: rgb(255 255 255 / 78%);
|
||||
--shadow-popover: 0 0.75rem 2rem rgb(0 0 0 / 35%);
|
||||
--shadow-modal: 0 1.5rem 4rem rgb(0 0 0 / 45%);
|
||||
}
|
||||
Loading…
Reference in new issue