use crate::embedded;
use std::io::ErrorKind;
use std::path::Path;
#[derive(Debug)]
pub struct Theme {
pub name: &'static str,
pub sheet_path: &'static str,
pub embedded: &'static [u8],
}
pub static THEMES: [Theme; 6] = [
Theme {
name: DEFAULT_THEME_NAME,
sheet_path: "css/error.css",
embedded: embedded::DEFAULT_SHEET,
},
Theme {
name: "minimal",
sheet_path: "themes/minimal.css",
embedded: include_bytes!("../content/themes/minimal.css"),
},
Theme {
name: "terminal",
sheet_path: "themes/terminal.css",
embedded: include_bytes!("../content/themes/terminal.css"),
},
Theme {
name: "paper",
sheet_path: "themes/paper.css",
embedded: include_bytes!("../content/themes/paper.css"),
},
Theme {
name: "amber",
sheet_path: "themes/amber.css",
embedded: include_bytes!("../content/themes/amber.css"),
},
Theme {
name: "compact",
sheet_path: "themes/compact.css",
embedded: include_bytes!("../content/themes/compact.css"),
},
];
pub const DEFAULT_THEME_NAME: &str = "default";
pub const THEME_NAMES: &str = "default, minimal, terminal, paper, amber, compact";
pub fn theme_by_name(name: &str) -> Option<&'static Theme> {
THEMES.iter().find(|theme| theme.name == name)
}
pub fn default_theme() -> &'static Theme {
&THEMES[0]
}
pub fn resolve(requested: Option<&str>, configured: &'static Theme) -> &'static Theme {
requested.and_then(theme_by_name).unwrap_or(configured)
}
impl Theme {
pub fn sheet(&self, content_dir: &Path) -> Vec<u8> {
let path = content_dir.join(self.sheet_path);
match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == ErrorKind::NotFound => self.embedded.to_vec(),
Err(err) => {
tracing::warn!(
theme = self.name,
path = %path.display(),
error = %err,
"no se pudo leer la hoja del tema: se usa la copia embebida"
);
self.embedded.to_vec()
}
}
}
}
#[cfg(test)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Incumplimiento {
Peticion(&'static str),
Movimiento(&'static str),
}
#[cfg(test)]
pub fn incumplimientos(hoja: &[u8]) -> Vec<Incumplimiento> {
let mut fallos = Vec::new();
let hoja = hoja_sin_comentarios(hoja);
if let Some(token) = token_que_pide(&hoja) {
fallos.push(Incumplimiento::Peticion(token));
}
if let Some(token) = token_que_anima(&hoja)
&& !puede_apagarse(&hoja)
{
fallos.push(Incumplimiento::Movimiento(token));
}
fallos
}
#[cfg(test)]
fn hoja_sin_comentarios(hoja: &[u8]) -> Vec<u8> {
let mut salida = Vec::with_capacity(hoja.len());
let mut i = 0;
while i < hoja.len() {
match hoja[i] {
b'/' if hoja.get(i + 1) == Some(&b'*') => {
match crate::render::busca(&hoja[i + 2..], b"*/") {
Some(cierre) => i += 2 + cierre + 2,
None => return salida,
}
}
comilla @ (b'"' | b'\'') => {
salida.push(comilla);
i += 1;
while i < hoja.len() {
let byte = hoja[i];
salida.push(byte);
i += 1;
if byte == b'\\' {
if let Some(escapado) = hoja.get(i) {
salida.push(*escapado);
i += 1;
}
} else if byte == comilla {
break;
}
}
}
byte => {
salida.push(byte);
i += 1;
}
}
}
salida
}
#[cfg(test)]
fn token_que_pide(hoja: &[u8]) -> Option<&'static str> {
if contiene(hoja, "@import") {
return Some("@import");
}
if tiene_url_externa(hoja) {
return Some("url(");
}
None
}
#[cfg(test)]
fn token_que_anima(hoja: &[u8]) -> Option<&'static str> {
["animation", "transition", "keyframes"]
.into_iter()
.find(|token| contiene(hoja, token))
}
#[cfg(test)]
fn puede_apagarse(hoja: &[u8]) -> bool {
contiene(hoja, "prefers-reduced-motion")
}
#[cfg(test)]
fn tiene_url_externa(hoja: &[u8]) -> bool {
const AGUJA: &[u8] = b"url(";
let mut desde = 0;
while let Some(pos) = crate::render::busca(hoja.get(desde..).unwrap_or_default(), AGUJA) {
let inicio = desde + pos + AGUJA.len();
let destino = hoja
.get(inicio..)
.unwrap_or_default()
.iter()
.find(|byte| !byte.is_ascii_whitespace());
if destino != Some(&b'#') {
return true;
}
desde = inicio;
}
false
}
#[cfg(test)]
fn contiene(pajar: &[u8], aguja: &str) -> bool {
String::from_utf8_lossy(pajar).contains(aguja)
}
#[cfg(test)]
mod tests {
use super::{
DEFAULT_THEME_NAME, Incumplimiento, THEME_NAMES, THEMES, default_theme, incumplimientos,
resolve, theme_by_name,
};
use crate::embedded;
use std::path::PathBuf;
fn content_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("content")
}
const NOMBRES: [&str; 6] = [
"default", "minimal", "terminal", "paper", "amber", "compact",
];
fn es_de_identificador(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_'
}
fn es_espacio(byte: u8) -> bool {
matches!(byte, b' ' | b'\t' | b'\n' | b'\r')
}
fn sin_comentarios(css: &str) -> String {
let mut salida = String::with_capacity(css.len());
let mut resto = css;
while let Some(inicio) = resto.find("/*") {
salida.push_str(&resto[..inicio]);
match resto[inicio + 2..].find("*/") {
Some(fin) => resto = &resto[inicio + 2 + fin + 2..],
None => return salida,
}
}
salida.push_str(resto);
salida
}
fn tiene_regla(css: &str, selector: &str) -> bool {
let css = sin_comentarios(css);
let bytes = css.as_bytes();
for (inicio, _) in css.match_indices(selector) {
if inicio > 0 && es_de_identificador(bytes[inicio - 1]) {
continue;
}
let mut cursor = inicio + selector.len();
if bytes.get(cursor).copied().is_some_and(es_de_identificador) {
continue;
}
while bytes.get(cursor).copied().is_some_and(es_espacio) {
cursor += 1;
}
if matches!(bytes.get(cursor), Some(b'{') | Some(b',')) {
return true;
}
}
false
}
#[test]
fn las_hojas_embebidas_son_byte_a_byte_las_de_content() {
for theme in THEMES.iter() {
let ruta = content_dir().join(theme.sheet_path);
let del_disco = match std::fs::read(&ruta) {
Ok(bytes) => bytes,
Err(error) => panic!(
"no se pudo leer {} (tema `{}`): {error}",
ruta.display(),
theme.name
),
};
assert_eq!(
theme.embedded,
del_disco.as_slice(),
"la hoja embebida del tema `{}` no coincide byte a byte con {}",
theme.name,
ruta.display()
);
}
}
#[test]
fn la_tabla_declara_los_seis_temas() {
assert_eq!(
THEMES.len(),
NOMBRES.len(),
"`THEMES` tiene que declarar los seis temas incorporados"
);
assert_eq!(
THEMES[0].name, DEFAULT_THEME_NAME,
"`THEMES[0]` tiene que ser el tema por defecto"
);
for (theme, nombre) in THEMES.iter().zip(NOMBRES) {
assert_eq!(
theme.name, nombre,
"el orden de `THEMES` tiene que ser el de la lista de nombres"
);
}
let enumerados: Vec<&str> = THEME_NAMES.split(',').map(str::trim).collect();
assert_eq!(
enumerados, NOMBRES,
"`THEME_NAMES` tiene que enumerar los seis nombres: {THEME_NAMES}"
);
}
#[test]
fn theme_by_name_solo_acepta_los_seis() {
for nombre in NOMBRES {
assert!(
theme_by_name(nombre).is_some(),
"`theme_by_name` tiene que encontrar el tema `{nombre}`"
);
}
for desconocido in [
"",
"neon",
"Default",
"TERMINAL",
"Themes/default",
"default ",
] {
assert!(
theme_by_name(desconocido).is_none(),
"`theme_by_name({desconocido:?})` tiene que ser `None`: la comparación es exacta"
);
}
}
#[test]
fn resolve_el_conocido_gana_y_lo_demas_cae_al_configurado() {
let configurado = theme_by_name("minimal").expect("`minimal` es uno de los seis");
assert_eq!(
resolve(Some("terminal"), configurado).name,
"terminal",
"un `X-Theme` conocido tiene que ganar al tema configurado"
);
assert_eq!(
resolve(Some("neon"), configurado).name,
"minimal",
"un `X-Theme` desconocido cae al configurado, no a otro tema"
);
assert_eq!(
resolve(Some(""), configurado).name,
"minimal",
"un `X-Theme` vacío no es un tema: cae al configurado"
);
assert_eq!(
resolve(None, configurado).name,
"minimal",
"sin `X-Theme` el tema es el configurado"
);
assert_eq!(
resolve(None, default_theme()).name,
DEFAULT_THEME_NAME,
"sin `X-Theme` y con el defecto configurado, el tema es `default`"
);
}
#[test]
fn cada_hoja_cubre_el_marcado() {
let mut fallos = Vec::new();
for theme in THEMES.iter() {
let hoja = String::from_utf8_lossy(theme.embedded);
for selector in ["body", "h1", "h2", "hr", "p"] {
if !tiene_regla(&hoja, selector) {
fallos.push(format!(
"la hoja del tema `{}` no define `{selector}`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"cada hoja de tema tiene que cubrir el marcado ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn ninguna_hoja_puede_hacer_una_peticion() {
let mut fallos = Vec::new();
for theme in THEMES.iter() {
for incumplimiento in incumplimientos(theme.embedded) {
if let Incumplimiento::Peticion(token) = incumplimiento {
fallos.push(format!(
"la hoja del tema `{}` contiene `{token}`: el navegador tendría que pedir \
algo por la red",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"ninguna hoja puede hacer que el navegador pida algo por la red ({} \
incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
const AUTOCONTENIDA: &str = r#"
.caja { fill: url(#gradiente); }
@font-face { font-family: Arial; src: local("Arial"); }
"#;
const ANIMADA_CON_FRENO: &str = r#"
@keyframes parpadeo { from { opacity: 1 } to { opacity: 0 } }
body { animation: parpadeo 2s infinite; }
@media (prefers-reduced-motion: reduce) { body { animation: none; } }
"#;
const ANIMADA_SIN_FRENO: &str = r#"
body { transition: opacity 0.2s; }
"#;
const QUIETA: &str = r#"
body { color: #222; }
"#;
const PIDEN_FICHERO: [(&str, &str); 3] = [
(r#"body { background: url(foto.png); }"#, "url("),
(
r#"body { background: url(https://ejemplo/x.png); }"#,
"url(",
),
(r#"@import "otra.css";"#, "@import"),
];
const COMENTADA: &str = r#"
/* Esta hoja no usa `animation` ni `@import`, y no referencia `url(` remotos. */
body { color: #222; }
"#;
const COMENTADA_CON_PETICION: &str = r#"
/* Nada de `url(` remotos por aquí. */
body { background: url(foto.png); }
"#;
const COMENTARIO_ABIERTO: &str = r#"
/* sin cerrar: `animation` y `@import` aquí dentro ya no son reglas
body { color: #222; }
"#;
const AUTOCONTENIDA_MULTIPLE: &str = r#"
.a { fill: url(#uno); }
.b { fill: url( #dos ); }
.c { fill: url(#); }
"#;
const INTERNA_Y_EXTERNA: &str = r#"
.a { background: image-set(url(#uno) 1x, url(foto.png) 2x); }
"#;
const CADENA_CON_BARRAS: &str = r#"
a::before { content: "/*"; }
body { background: url(foto.png); }
"#;
#[test]
fn una_referencia_al_propio_documento_no_es_una_peticion() {
assert!(
incumplimientos(AUTOCONTENIDA.as_bytes()).is_empty(),
"`url(#…)` y `src: local(…)` no son peticiones: no hay nada que el navegador tenga que \
pedir, así que la hoja tiene que cumplir el contrato. Hoja: {AUTOCONTENIDA:?}"
);
}
#[test]
fn una_hoja_que_pide_un_fichero_incumple() {
for (hoja, token) in PIDEN_FICHERO {
assert!(
incumplimientos(hoja.as_bytes()).contains(&Incumplimiento::Peticion(token)),
"esta hoja pide un fichero con `{token}` y tiene que incumplirlo **nombrando ese \
token**: {hoja:?}"
);
}
}
#[test]
fn una_hoja_que_anima_solo_cumple_si_se_puede_apagar() {
assert!(
incumplimientos(ANIMADA_SIN_FRENO.as_bytes())
.iter()
.any(|i| matches!(i, Incumplimiento::Movimiento(_))),
"una hoja que anima y no declara `prefers-reduced-motion` tiene que incumplir: \
{ANIMADA_SIN_FRENO:?}"
);
assert!(
incumplimientos(ANIMADA_CON_FRENO.as_bytes()).is_empty(),
"una hoja que anima **y** declara `prefers-reduced-motion` tiene que cumplir: \
{ANIMADA_CON_FRENO:?}"
);
assert!(
incumplimientos(QUIETA.as_bytes()).is_empty(),
"una hoja sin animación tiene que cumplir sin declarar nada: {QUIETA:?}"
);
}
#[test]
fn una_hoja_que_anima_respeta_reduced_motion() {
let mut fallos = Vec::new();
for theme in THEMES.iter() {
for incumplimiento in incumplimientos(theme.embedded) {
if let Incumplimiento::Movimiento(token) = incumplimiento {
fallos.push(format!(
"la hoja del tema `{}` anima con `{token}` y no declara \
`prefers-reduced-motion`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"una hoja que anima tiene que poder apagarse ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn una_hoja_quieta_cumple_los_dos_contratos() {
assert!(
incumplimientos(QUIETA.as_bytes()).is_empty(),
"una hoja que no pide nada y no anima tiene que cumplir: {QUIETA:?}"
);
}
#[test]
fn un_comentario_no_es_una_peticion_ni_una_animacion() {
assert!(
incumplimientos(COMENTADA.as_bytes()).is_empty(),
"un `url(`, un `@import` o un `animation` dentro de un comentario no son el contrato: \
un comentario no puede hacer que el navegador pida nada ni puede animar. \
Hoja: {COMENTADA:?}"
);
assert!(
incumplimientos(COMENTADA_CON_PETICION.as_bytes())
.iter()
.any(|i| matches!(i, Incumplimiento::Peticion(_))),
"limpiar los comentarios no puede tapar una petición de verdad: el comentario menciona \
`url(` y la regla trae un `url(foto.png)`. Hoja: {COMENTADA_CON_PETICION:?}"
);
}
#[test]
fn un_comentario_sin_cerrar_se_ignora() {
assert!(
incumplimientos(COMENTARIO_ABIERTO.as_bytes()).is_empty(),
"un comentario que abre y no cierra llega hasta el final de la hoja: lo que queda \
detrás es prosa, no reglas. Hoja: {COMENTARIO_ABIERTO:?}"
);
}
#[test]
fn el_contrato_de_red_mira_todas_las_referencias() {
assert!(
incumplimientos(AUTOCONTENIDA_MULTIPLE.as_bytes()).is_empty(),
"varias referencias internas —una con espacios de por medio y otra con el fragmento \
vacío— siguen sin ser peticiones: {AUTOCONTENIDA_MULTIPLE:?}"
);
assert!(
incumplimientos(INTERNA_Y_EXTERNA.as_bytes())
.contains(&Incumplimiento::Peticion("url(")),
"una interna **y** una externa en la misma declaración: la externa tiene que \
detectarse igual: {INTERNA_Y_EXTERNA:?}"
);
}
#[test]
fn una_cadena_con_barras_no_abre_un_comentario() {
assert!(
incumplimientos(CADENA_CON_BARRAS.as_bytes())
.contains(&Incumplimiento::Peticion("url(")),
"un `/*` dentro de una cadena es CSS válido, no un comentario: no puede tapar el \
`url(foto.png)` que viene detrás. Hoja: {CADENA_CON_BARRAS:?}"
);
assert!(
incumplimientos(br#"a { background: url("a/*b.png"); }"#)
.contains(&Incumplimiento::Peticion("url(")),
"una URL con `/*` dentro sigue siendo una petición externa"
);
assert!(
incumplimientos(br#"/* El operador dice: "no uses url( remotos". */ body { background: url(foto.png); }"#)
.contains(&Incumplimiento::Peticion("url(")),
"una comilla dentro de un comentario no puede desactivar el limpiador: el comentario se \
va entero y la petición de detrás tiene que seguir viéndose"
);
}
#[test]
fn ninguna_hoja_puede_cerrar_el_comentario_ni_el_style() {
const PROHIBIDOS: [&str; 2] = ["-->", "</style>"];
let mut fallos = Vec::new();
for theme in THEMES.iter() {
let hoja = String::from_utf8_lossy(theme.embedded);
for prohibido in PROHIBIDOS {
if hoja.contains(prohibido) {
fallos.push(format!(
"la hoja del tema `{}` contiene `{prohibido}`",
theme.name
));
}
}
}
assert!(
fallos.is_empty(),
"ninguna hoja puede contener `-->` ni `</style>` ({} incumplimiento(s)):\n- {}",
fallos.len(),
fallos.join("\n- ")
);
}
#[test]
fn la_hoja_default_es_la_del_asset() {
let (bytes, content_type) = embedded::asset("/css/error.css")
.expect("la hoja `/css/error.css` tiene que estar en la tabla de assets");
assert_eq!(
content_type, "text/css",
"Content-Type del asset de la hoja"
);
let default = default_theme();
assert_eq!(
default.sheet_path, "css/error.css",
"la hoja del tema `default` es el asset público"
);
assert!(
std::ptr::eq(default.embedded, bytes),
"el tema `default` y el asset tienen que **compartir la hoja** —el mismo puntero, no \
solo los mismos bytes—, pero `THEMES[0].embedded` y el asset apuntan a direcciones \
distintas"
);
}
}