feat(themes): add decoupled CSS theme system with lightningcss bundling

- Add 6 syntax highlighting themes (dracula, gruvbox, nord, github)
- Rewrite css.rs to use lightningcss bundler for @import resolution
- Theme CSS is inlined at build time, producing single bundled output
This commit is contained in:
Timothy DeHerrera
2026-02-05 12:19:47 -07:00
parent f9a978bdd6
commit caf2d506a7
10 changed files with 2083 additions and 172 deletions

View File

@@ -1,31 +1,38 @@
//! CSS processing via lightningcss.
//!
//! Provides CSS bundling and minification. The bundler resolves `@import`
//! rules at build time, inlining imported files into a single output.
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions, StyleSheet};
use lightningcss::bundler::{Bundler, FileProvider};
use lightningcss::stylesheet::{MinifyOptions, ParserOptions, PrinterOptions};
use std::path::Path;
/// Minify CSS content.
/// Bundle and minify a CSS file, resolving all `@import` rules.
///
/// Returns minified CSS string on success, or the original input on error.
pub fn minify_css(css: &str) -> String {
match try_minify(css) {
Ok(minified) => minified,
Err(_) => css.to_string(),
}
}
/// This function:
/// 1. Reads the CSS file at `path`
/// 2. Resolves and inlines all `@import` rules (relative to source file)
/// 3. Minifies the combined output
///
/// Returns minified CSS string on success, or an error message on failure.
pub fn bundle_css(path: &Path) -> Result<String, String> {
let fs = FileProvider::new();
let mut bundler = Bundler::new(&fs, None, ParserOptions::default());
fn try_minify(css: &str) -> Result<String, Box<dyn std::error::Error>> {
let mut stylesheet = StyleSheet::parse(css, ParserOptions::default())
.map_err(|e| format!("parse error: {:?}", e))?;
let mut stylesheet = bundler
.bundle(path)
.map_err(|e| format!("bundle error: {e}"))?;
stylesheet
.minify(MinifyOptions::default())
.map_err(|e| format!("minify error: {:?}", e))?;
.map_err(|e| format!("minify error: {e}"))?;
let result = stylesheet
.to_css(PrinterOptions {
minify: true,
..Default::default()
})
.map_err(|e| format!("print error: {:?}", e))?;
.map_err(|e| format!("print error: {e}"))?;
Ok(result.code)
}
@@ -33,48 +40,89 @@ fn try_minify(css: &str) -> Result<String, Box<dyn std::error::Error>> {
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_minify_removes_whitespace() {
let input = r#"
fn test_bundle_minifies() {
let dir = TempDir::new().unwrap();
let css_path = dir.path().join("test.css");
fs::write(
&css_path,
r#"
.foo {
color: red;
}
"#;
let output = minify_css(input);
"#,
)
.unwrap();
// Should be smaller (whitespace removed)
assert!(output.len() < input.len());
// Should still contain the essential content
let output = bundle_css(&css_path).unwrap();
// Should be minified (whitespace removed)
assert!(output.contains(".foo"));
assert!(output.contains("color"));
assert!(output.contains("red"));
assert!(!output.contains('\n'));
}
#[test]
fn test_minify_removes_comments() {
let input = r#"
fn test_bundle_resolves_imports() {
let dir = TempDir::new().unwrap();
// Create imported file
let imported_path = dir.path().join("colors.css");
fs::write(
&imported_path,
r#"
:root {
--primary: blue;
}
"#,
)
.unwrap();
// Create main file that imports colors.css
let main_path = dir.path().join("main.css");
fs::write(
&main_path,
r#"
@import "colors.css";
.btn {
color: var(--primary);
}
"#,
)
.unwrap();
let output = bundle_css(&main_path).unwrap();
// Should contain content from both files
assert!(output.contains("--primary"));
assert!(output.contains("blue"));
assert!(output.contains(".btn"));
// Should NOT contain @import directive
assert!(!output.contains("@import"));
}
#[test]
fn test_bundle_removes_comments() {
let dir = TempDir::new().unwrap();
let css_path = dir.path().join("test.css");
fs::write(
&css_path,
r#"
/* This is a comment */
.bar { background: blue; }
"#;
let output = minify_css(input);
"#,
)
.unwrap();
let output = bundle_css(&css_path).unwrap();
// Comment should be removed
assert!(!output.contains("This is a comment"));
// Rule should remain
assert!(output.contains(".bar"));
}
#[test]
fn test_minify_merges_selectors() {
let input = r#"
.foo { color: red; }
.bar { color: red; }
"#;
let output = minify_css(input);
// Should merge identical rules
// Either ".foo,.bar" or ".bar,.foo" pattern
assert!(output.contains(","));
}
}

View File

@@ -52,6 +52,10 @@ pub enum Error {
#[source]
source: tera::Error,
},
/// Failed to bundle CSS.
#[error("CSS bundle error: {0}")]
CssBundle(String),
}
/// Result type alias for compiler operations.

View File

@@ -338,7 +338,7 @@ fn write_output(
/// Copy static assets (CSS, images, etc.) to output directory.
/// CSS files are minified before writing.
fn copy_static_assets(static_dir: &Path, output_dir: &Path) -> Result<()> {
use crate::css::minify_css;
use crate::css::bundle_css;
if !static_dir.exists() {
return Ok(()); // No static dir is fine
@@ -365,23 +365,20 @@ fn copy_static_assets(static_dir: &Path, output_dir: &Path) -> Result<()> {
})?;
}
// Minify CSS files, copy others directly
// Bundle CSS files (resolves @imports), copy others directly
if src.extension().is_some_and(|ext| ext == "css") {
let css = fs::read_to_string(src).map_err(|e| Error::ReadFile {
path: src.to_path_buf(),
source: e,
})?;
let minified = minify_css(&css);
fs::write(&dest, &minified).map_err(|e| Error::WriteFile {
let original_size = fs::metadata(src).map(|m| m.len()).unwrap_or(0);
let bundled = bundle_css(src).map_err(Error::CssBundle)?;
fs::write(&dest, &bundled).map_err(|e| Error::WriteFile {
path: dest.clone(),
source: e,
})?;
eprintln!(
"minifying: {}{} ({}{} bytes)",
"bundling: {}{} ({}{} bytes)",
src.display(),
dest.display(),
css.len(),
minified.len()
original_size,
bundled.len()
);
} else {
fs::copy(src, &dest).map_err(|e| Error::WriteFile {