From f1d5c4a4fdbd39a6ce59a265bcf6158362151e58 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 16 May 2026 23:48:57 +0200 Subject: [PATCH] added testing --- .github/workflows/ci.yml | 51 +++ backend/rustfmt.toml | 2 + backend/src/error.rs | 6 +- backend/src/handlers/config.rs | 40 +- backend/src/handlers/contact.rs | 19 +- backend/src/handlers/posts.rs | 170 ++++++-- backend/src/handlers/upload.rs | 18 +- backend/src/main.rs | 16 +- backend/src/models.rs | 5 +- frontend/biome.json | 56 +++ frontend/package-lock.json | 618 +++++++++++++++++++++++++++- frontend/package.json | 14 +- frontend/src/components/ui/Alert.ts | 32 -- frontend/src/lib/cybersigil.test.ts | 72 ++++ frontend/src/pages/api/[...path].ts | 4 +- frontend/vitest.config.ts | 10 + 16 files changed, 1014 insertions(+), 119 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 backend/rustfmt.toml create mode 100644 frontend/biome.json delete mode 100644 frontend/src/components/ui/Alert.ts create mode 100644 frontend/src/lib/cybersigil.test.ts create mode 100644 frontend/vitest.config.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..73145c0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - name: Type-check + run: npm run check + - name: Lint + run: npm run lint + - name: Test + run: npm test + - name: Build + run: npm run build + + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + with: + workspaces: backend + - name: Format + run: cargo fmt --check + - name: Clippy + # Warnings are surfaced but not yet gating — ratchet to `-D warnings` + # once the existing ~10 style lints are cleared. + run: cargo clippy --all-targets + - name: Test + run: cargo test diff --git a/backend/rustfmt.toml b/backend/rustfmt.toml new file mode 100644 index 0000000..ed49ca7 --- /dev/null +++ b/backend/rustfmt.toml @@ -0,0 +1,2 @@ +edition = "2024" +max_width = 100 diff --git a/backend/src/error.rs b/backend/src/error.rs index aabc57d..921d2a9 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -7,6 +7,7 @@ use tracing::error; use crate::models::ErrorResponse; +#[derive(Debug)] pub enum AppError { Unauthorized, NotFound(String), @@ -29,7 +30,10 @@ impl IntoResponse for AppError { } else { error!("Internal error: {}", msg); } - (StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string()) + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal error".to_string(), + ) } }; diff --git a/backend/src/handlers/config.rs b/backend/src/handlers/config.rs index 0364cbc..50ac9ce 100644 --- a/backend/src/handlers/config.rs +++ b/backend/src/handlers/config.rs @@ -35,16 +35,36 @@ pub async fn update_config( .and_then(|c| serde_json::from_str(&c).ok()) .unwrap_or_default(); - if let Some(v) = patch.title { config.title = v; } - if let Some(v) = patch.subtitle { config.subtitle = v; } - if let Some(v) = patch.welcome_title { config.welcome_title = v; } - if let Some(v) = patch.welcome_subtitle { config.welcome_subtitle = v; } - if let Some(v) = patch.footer { config.footer = v; } - if let Some(v) = patch.favicon { config.favicon = v; } - if let Some(v) = patch.theme { config.theme = v; } - if let Some(v) = patch.custom_css { config.custom_css = v; } - if let Some(v) = patch.contact_intro { config.contact_intro = v; } - if let Some(v) = patch.contact_links { config.contact_links = v; } + if let Some(v) = patch.title { + config.title = v; + } + if let Some(v) = patch.subtitle { + config.subtitle = v; + } + if let Some(v) = patch.welcome_title { + config.welcome_title = v; + } + if let Some(v) = patch.welcome_subtitle { + config.welcome_subtitle = v; + } + if let Some(v) = patch.footer { + config.footer = v; + } + if let Some(v) = patch.favicon { + config.favicon = v; + } + if let Some(v) = patch.theme { + config.theme = v; + } + if let Some(v) = patch.custom_css { + config.custom_css = v; + } + if let Some(v) = patch.contact_intro { + config.contact_intro = v; + } + if let Some(v) = patch.contact_links { + config.contact_links = v; + } let config_str = serde_json::to_string_pretty(&config).map_err(|e| { error!("Serialization error: {}", e); diff --git a/backend/src/handlers/contact.rs b/backend/src/handlers/contact.rs index 131edb2..cd80206 100644 --- a/backend/src/handlers/contact.rs +++ b/backend/src/handlers/contact.rs @@ -123,7 +123,10 @@ pub async fn submit_contact( if name.as_ref().is_some_and(|s| s.chars().count() > MAX_NAME) { return Err(AppError::BadRequest("Name is too long.".into())); } - if email.as_ref().is_some_and(|s| s.chars().count() > MAX_EMAIL) { + if email + .as_ref() + .is_some_and(|s| s.chars().count() > MAX_EMAIL) + { return Err(AppError::BadRequest("Email is too long.".into())); } if subject @@ -160,9 +163,8 @@ pub async fn submit_contact( AppError::Internal("Storage error".into(), Some(e.to_string())) })?; let path = messages_dir.join(format!("{}.json", id)); - let json = serde_json::to_string_pretty(&msg).map_err(|e| { - AppError::Internal("Serialization error".into(), Some(e.to_string())) - })?; + let json = serde_json::to_string_pretty(&msg) + .map_err(|e| AppError::Internal("Serialization error".into(), Some(e.to_string())))?; fs::write(&path, json).await.map_err(|e| { error!("Failed to write message {}: {}", id, e); AppError::Internal("Storage error".into(), Some(e.to_string())) @@ -189,8 +191,7 @@ pub async fn list_messages( Ok(e) => e, Err(e) => { error!("Failed to read messages dir: {}", e); - return AppError::Internal("Read error".into(), Some(e.to_string())) - .into_response(); + return AppError::Internal("Read error".into(), Some(e.to_string())).into_response(); } }; @@ -239,8 +240,8 @@ pub async fn delete_message( if !fs::try_exists(&path).await.unwrap_or(false) { return Err(AppError::NotFound("Message not found.".into())); } - fs::remove_file(&path).await.map_err(|e| { - AppError::Internal("Delete failed".into(), Some(e.to_string())) - })?; + fs::remove_file(&path) + .await + .map_err(|e| AppError::Internal("Delete failed".into(), Some(e.to_string())))?; Ok(Json(ContactResponse { ok: true })) } diff --git a/backend/src/handlers/posts.rs b/backend/src/handlers/posts.rs index 45763ee..a175f02 100644 --- a/backend/src/handlers/posts.rs +++ b/backend/src/handlers/posts.rs @@ -21,9 +21,8 @@ const WORDS_PER_MINUTE: u32 = 200; const MAX_SLUG_LEN: usize = 100; const WINDOWS_RESERVED: &[&str] = &[ - "CON", "PRN", "AUX", "NUL", - "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", - "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", + "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", + "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", ]; fn validate_slug(s: &str) -> Result<(), AppError> { @@ -47,9 +46,7 @@ fn validate_slug(s: &str) -> Result<(), AppError> { )); } if s.contains("..") { - return Err(AppError::BadRequest( - "Slug cannot contain '..'".to_string(), - )); + return Err(AppError::BadRequest("Slug cannot contain '..'".to_string())); } for c in s.chars() { if c.is_control() { @@ -66,15 +63,15 @@ fn validate_slug(s: &str) -> Result<(), AppError> { } let stem = s.split('.').next().unwrap_or("").to_ascii_uppercase(); if WINDOWS_RESERVED.iter().any(|r| *r == stem) { - return Err(AppError::BadRequest( - "Slug is a reserved name".to_string(), - )); + return Err(AppError::BadRequest("Slug is a reserved name".to_string())); } Ok(()) } fn split_frontmatter(raw: &str) -> Option<(&str, &str)> { - let raw = raw.strip_prefix("---\n").or_else(|| raw.strip_prefix("---\r\n"))?; + let raw = raw + .strip_prefix("---\n") + .or_else(|| raw.strip_prefix("---\r\n"))?; let end_marker = raw.find("\n---\n").or_else(|| raw.find("\r\n---\r\n"))?; let yaml = &raw[..end_marker]; let body_start = end_marker @@ -82,7 +79,9 @@ fn split_frontmatter(raw: &str) -> Option<(&str, &str)> { .find("---\n") .or_else(|| raw[end_marker..].find("---\r\n"))? + "---\n".len(); - let body = raw[body_start..].trim_start_matches('\n').trim_start_matches('\r'); + let body = raw[body_start..] + .trim_start_matches('\n') + .trim_start_matches('\r'); Some((yaml, body)) } @@ -103,12 +102,8 @@ fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> { } fn serialize_post(meta: &PostMeta, body: &str) -> Result { - let yaml = serde_yaml::to_string(meta).map_err(|e| { - AppError::Internal( - "Serialization error".to_string(), - Some(e.to_string()), - ) - })?; + let yaml = serde_yaml::to_string(meta) + .map_err(|e| AppError::Internal("Serialization error".to_string(), Some(e.to_string())))?; Ok(format!("---\n{}---\n{}", yaml, body)) } @@ -176,11 +171,7 @@ fn cover_from(images: &[(String, String)]) -> Option { /// bytes via `imagesize::size`, off the runtime via `spawn_blocking`. async fn compute_dim_from_url(state: &AppState, url: &str) -> Option { let name = url.strip_prefix("/uploads/")?; - if name.is_empty() - || name.contains("..") - || name.contains('\\') - || name.starts_with('/') - { + if name.is_empty() || name.contains("..") || name.contains('\\') || name.starts_with('/') { return None; } let path = state.data_dir.join("uploads").join(name); @@ -254,9 +245,7 @@ fn excerpt_from(meta: &PostMeta, body: &str) -> String { return s.trim().to_string(); } } - let plain = body - .replace(['#', '*', '_', '`'], "") - .replace('\n', " "); + let plain = body.replace(['#', '*', '_', '`'], "").replace('\n', " "); let mut out: String = plain.chars().take(200).collect(); if plain.chars().count() > 200 { out.push_str("..."); @@ -340,17 +329,16 @@ pub async fn rebuild_posts_cache(state: &AppState) { *state.posts_cache.write().await = posts; } -async fn write_post_atomic( - state: &AppState, - slug: &str, - contents: &str, -) -> Result<(), AppError> { +async fn write_post_atomic(state: &AppState, slug: &str, contents: &str) -> Result<(), AppError> { let _guard = state.post_lock.lock().await; let final_path = state.data_dir.join("posts").join(format!("{}.md", slug)); - let tmp_path = state.data_dir.join("posts").join(format!(".{}.md.tmp", slug)); - fs::write(&tmp_path, contents).await.map_err(|e| { - AppError::Internal("Write error".to_string(), Some(e.to_string())) - })?; + let tmp_path = state + .data_dir + .join("posts") + .join(format!(".{}.md.tmp", slug)); + fs::write(&tmp_path, contents) + .await + .map_err(|e| AppError::Internal("Write error".to_string(), Some(e.to_string())))?; if let Err(e) = fs::rename(&tmp_path, &final_path).await { let _ = fs::remove_file(&tmp_path).await; return Err(AppError::Internal( @@ -407,13 +395,17 @@ pub async fn create_post( let images = extract_images(&payload.content); if images.is_empty() { return Err(AppError::BadRequest( - "A gallery entry must include at least one image (![](url) in the markdown body).".to_string(), + "A gallery entry must include at least one image (![](url) in the markdown body)." + .to_string(), )); } let meta = PostMeta { date: payload.date.unwrap_or_else(|| Utc::now().date_naive()), - title: payload.title.map(|t| t.trim().to_string()).filter(|t| !t.is_empty()), + title: payload + .title + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()), summary: payload.summary.filter(|s| !s.trim().is_empty()), tags: payload.tags, draft: payload.draft, @@ -515,7 +507,11 @@ async fn neighbors_from_cache( slug: p.slug.clone(), title: p.title.clone(), }; - let prev = if i > 0 { Some(to_neighbor(visible[i - 1])) } else { None }; + let prev = if i > 0 { + Some(to_neighbor(visible[i - 1])) + } else { + None + }; let next = visible.get(i + 1).map(|p| to_neighbor(p)); (prev, next) } @@ -541,10 +537,7 @@ pub async fn get_post( let (prev, next) = neighbors_from_cache(&state, &slug, admin).await; - let image_urls: Vec = extract_images(&body) - .into_iter() - .map(|(_, u)| u) - .collect(); + let image_urls: Vec = extract_images(&body).into_iter().map(|(_, u)| u).collect(); let dimensions = dims_for_urls(&state, &image_urls).await; Ok(Json(PostDetail { @@ -563,3 +556,98 @@ pub async fn get_post( dimensions, })) } + +#[cfg(test)] +mod tests { + use super::{ + cover_from, extract_images, parse_post, reading_time, split_frontmatter, validate_slug, + }; + use crate::error::AppError; + + #[test] + fn validate_slug_accepts_normal_slugs() { + assert!(validate_slug("hello-world").is_ok()); + assert!(validate_slug("a_b.c-123").is_ok()); + } + + #[test] + fn validate_slug_rejects_traversal_and_bad_chars() { + for bad in [ + "", + "../etc", + "with/slash", + "back\\slash", + "ends.", + "trailing ", + ".hidden", + ] { + assert!( + matches!(validate_slug(bad), Err(AppError::BadRequest(_))), + "expected {bad:?} to be rejected" + ); + } + let too_long = "x".repeat(101); + assert!(validate_slug(&too_long).is_err()); + assert!(matches!(validate_slug("CON"), Err(AppError::BadRequest(_)))); + } + + #[test] + fn split_frontmatter_handles_lf_and_crlf() { + let (yaml, body) = split_frontmatter("---\ndate: 2026-05-16\n---\nHello").unwrap(); + assert_eq!(yaml, "date: 2026-05-16"); + assert_eq!(body, "Hello"); + + let (y2, b2) = split_frontmatter("---\r\ndate: 2026-05-16\r\n---\r\nHi").unwrap(); + assert!(y2.contains("date: 2026-05-16")); + assert_eq!(b2, "Hi"); + + assert!(split_frontmatter("no frontmatter here").is_none()); + } + + #[test] + fn parse_post_reads_meta_and_body() { + let raw = "---\ndate: 2026-05-16\ntitle: Hello\ndraft: true\n---\nBody text"; + let (meta, body) = parse_post(raw).unwrap(); + assert_eq!(meta.title.as_deref(), Some("Hello")); + assert!(meta.draft); + assert_eq!(meta.date.to_string(), "2026-05-16"); + assert_eq!(body, "Body text"); + + assert!(parse_post("no frontmatter").is_err()); + } + + #[test] + fn reading_time_rounds_up_by_wpm() { + assert_eq!(reading_time(""), 0); + assert_eq!(reading_time("one"), 1); + assert_eq!(reading_time(&"word ".repeat(200)), 1); + assert_eq!(reading_time(&"word ".repeat(201)), 2); + } + + #[test] + fn extract_images_skips_fences_and_strips_titles() { + let md = "intro\n\ + ![a](/u/one.png)\n\ + ```\n\ + ![skip](/u/hidden.png)\n\ + ```\n\ + ![c](/u/two.png \"a title\")"; + let imgs = extract_images(md); + assert_eq!( + imgs, + vec![ + ("a".to_string(), "/u/one.png".to_string()), + ("c".to_string(), "/u/two.png".to_string()), + ] + ); + } + + #[test] + fn cover_from_takes_first_or_none() { + assert!(cover_from(&[]).is_none()); + let imgs = vec![("alt".to_string(), "/u/first.png".to_string())]; + let cover = cover_from(&imgs).unwrap(); + assert_eq!(cover.url, "/u/first.png"); + assert_eq!(cover.alt, "alt"); + } +} diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index 98c8555..b8e39ce 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -23,10 +23,8 @@ pub struct UploadQuery { /// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent — /// /uploads/* is served as-is, so any active content there is XSS waiting to happen. const ALLOWED_EXTS: &[&str] = &[ - "jpg", "jpeg", "png", "webp", "gif", "avif", - "pdf", "txt", "md", - "mp3", "wav", "ogg", - "mp4", "webm", "mov", + "jpg", "jpeg", "png", "webp", "gif", "avif", "pdf", "txt", "md", "mp3", "wav", "ogg", "mp4", + "webm", "mov", ]; fn validate_filename(name: &str) -> Result<(), AppError> { @@ -77,9 +75,9 @@ pub async fn delete_upload( let uploads_dir = state.data_dir.join("uploads"); let file_path = uploads_dir.join(&filename); - let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| { - AppError::Internal("Path resolution".to_string(), Some(e.to_string())) - })?; + let canonical_dir = fs::canonicalize(&uploads_dir) + .await + .map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?; if let Ok(canonical_file) = fs::canonicalize(&file_path).await { if !canonical_file.starts_with(&canonical_dir) { warn!("Refused delete outside uploads dir: {}", filename); @@ -209,9 +207,9 @@ pub async fn upload_file( }; // Final containment check. - let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| { - AppError::Internal("Path resolution".to_string(), Some(e.to_string())) - })?; + let canonical_dir = fs::canonicalize(&uploads_dir) + .await + .map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?; if let Some(parent) = final_path.parent() { let canonical_parent = fs::canonicalize(parent).await.map_err(|e| { AppError::Internal("Path resolution".to_string(), Some(e.to_string())) diff --git a/backend/src/main.rs b/backend/src/main.rs index f7030c3..0f894c4 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -47,9 +47,7 @@ async fn main() { .filter(|t| !t.trim().is_empty()) .expect("ADMIN_TOKEN must be set to a non-empty value"); if admin_token.len() < 16 { - warn!( - "ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production." - ); + warn!("ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production."); } let data_dir_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string()); let data_dir = PathBuf::from(data_dir_str); @@ -79,7 +77,10 @@ async fn main() { }); handlers::posts::rebuild_posts_cache(&state).await; - info!("Posts cache primed with {} entries", state.posts_cache.read().await.len()); + info!( + "Posts cache primed with {} entries", + state.posts_cache.read().await.len() + ); spawn_rate_limit_reaper(state.clone()); @@ -89,8 +90,8 @@ async fn main() { // server-to-server and not subject to CORS. let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) { Some(origin) => { - let value = HeaderValue::from_str(&origin) - .expect("FRONTEND_ORIGIN must be a valid origin URL"); + let value = + HeaderValue::from_str(&origin).expect("FRONTEND_ORIGIN must be a valid origin URL"); CorsLayer::new() .allow_origin(AllowOrigin::exact(value)) .allow_methods([ @@ -132,8 +133,7 @@ async fn main() { ) .route( "/api/upload", - post(handlers::upload::upload_file) - .layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)), + post(handlers::upload::upload_file).layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)), ) .route("/api/contact", post(handlers::contact::submit_contact)) .route("/api/messages", get(handlers::contact::list_messages)) diff --git a/backend/src/models.rs b/backend/src/models.rs index 60d565b..29af6e9 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -31,9 +31,8 @@ impl Default for SiteConfig { title: "Ela's Atelier".to_string(), subtitle: "Works on paper, canvas, and elsewhere".to_string(), welcome_title: "Works on view".to_string(), - welcome_subtitle: - "An ongoing arrangement of pieces, sketches, and stray observations." - .to_string(), + welcome_subtitle: "An ongoing arrangement of pieces, sketches, and stray observations." + .to_string(), footer: "Hand-arranged with care".to_string(), favicon: "/favicon.svg".to_string(), theme: "salon".to_string(), diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..46b5c35 --- /dev/null +++ b/frontend/biome.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["src/**/*.{ts,tsx,js,jsx,json}", "*.{ts,js,json}", "!**/dist", "!**/.astro"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noExplicitAny": "off", + "noControlCharactersInRegex": "warn", + "noArrayIndexKey": "warn" + }, + "correctness": { + "useExhaustiveDependencies": "warn" + }, + "security": { + "noDangerouslySetInnerHtml": "warn" + }, + "a11y": { + "noLabelWithoutControl": "warn", + "noSvgWithoutTitle": "warn", + "useKeyWithClickEvents": "warn", + "useButtonType": "warn", + "noStaticElementInteractions": "warn", + "useSemanticElements": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "semicolons": "always" + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2900289..94d4623 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -41,11 +41,13 @@ }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@biomejs/biome": "^2.0.0", "@types/katex": "^0.16.8", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.0.0" }, "engines": { "node": ">=22.12.0" @@ -608,6 +610,181 @@ "node": ">=6.9.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz", + "integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.4.15", + "@biomejs/cli-darwin-x64": "2.4.15", + "@biomejs/cli-linux-arm64": "2.4.15", + "@biomejs/cli-linux-arm64-musl": "2.4.15", + "@biomejs/cli-linux-x64": "2.4.15", + "@biomejs/cli-linux-x64-musl": "2.4.15", + "@biomejs/cli-win32-arm64": "2.4.15", + "@biomejs/cli-win32-x64": "2.4.15" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz", + "integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz", + "integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz", + "integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz", + "integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz", + "integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz", + "integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz", + "integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz", + "integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, "node_modules/@bramus/specificity": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", @@ -3305,6 +3482,17 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3314,6 +3502,13 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -3427,6 +3622,131 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/kit": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", @@ -3642,6 +3962,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astro": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/astro/-/astro-6.0.8.tgz", @@ -3799,6 +4129,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001781", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", @@ -3829,6 +4169,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/character-entities": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", @@ -3859,6 +4216,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", @@ -4160,6 +4527,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/defu": { "version": "6.1.4", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", @@ -4483,6 +4860,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -5436,6 +5823,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", @@ -6580,6 +6974,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -7172,6 +7583,13 @@ "node": ">=20" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -7209,6 +7627,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7218,6 +7643,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7260,6 +7692,26 @@ "node": ">=8" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -7322,6 +7774,13 @@ "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyclip": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", @@ -7356,6 +7815,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tldts": { "version": "7.0.30", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", @@ -7914,6 +8403,36 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/vitefu": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", @@ -7933,6 +8452,86 @@ } } }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/volar-service-css": { "version": "0.0.70", "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", @@ -8257,6 +8856,23 @@ "node": ">=4" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5ba44fd..16a68c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,7 +9,15 @@ "dev": "astro dev", "build": "astro build", "preview": "astro preview", - "astro": "astro" + "astro": "astro", + "check": "astro check", + "lint": "biome lint .", + "lint:fix": "biome lint --write .", + "format": "biome format --write .", + "format:check": "biome format .", + "test": "vitest run", + "test:watch": "vitest", + "verify": "astro check && biome lint . && vitest run" }, "dependencies": { "@astrojs/node": "^10.0.3", @@ -45,10 +53,12 @@ }, "devDependencies": { "@astrojs/check": "^0.9.9", + "@biomejs/biome": "^2.0.0", "@types/katex": "^0.16.8", "@types/node": "^25.5.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.0.0" } } diff --git a/frontend/src/components/ui/Alert.ts b/frontend/src/components/ui/Alert.ts deleted file mode 100644 index 17b4ae8..0000000 --- a/frontend/src/components/ui/Alert.ts +++ /dev/null @@ -1,32 +0,0 @@ -export function showAlert(msg: string, type: 'success' | 'error', elementId: string = 'alert') { - const alertEl = document.getElementById(elementId); - if (alertEl) { - alertEl.textContent = msg; - - // Define colors based on type using theme variables - const colorVar = type === 'success' ? 'var(--green)' : 'var(--red)'; - - // Apply inline styles for guaranteed high contrast and glassy look - alertEl.style.display = 'block'; - alertEl.style.backgroundColor = `color-mix(in srgb, ${colorVar} 15%, transparent)`; - alertEl.style.color = 'var(--text)'; - alertEl.style.border = `1px solid color-mix(in srgb, ${colorVar} 40%, transparent)`; - alertEl.style.padding = '1rem'; - alertEl.style.borderRadius = '0.75rem'; - alertEl.style.marginBottom = '1.5rem'; - alertEl.style.fontSize = '0.875rem'; - alertEl.style.fontWeight = '600'; - alertEl.style.backdropFilter = 'blur(12px)'; - alertEl.style.textAlign = 'center'; - alertEl.style.boxShadow = '0 4px 15px -5px rgba(0,0,0,0.3)'; - - alertEl.classList.remove('hidden'); - - window.scrollTo({ top: 0, behavior: 'smooth' }); - - setTimeout(() => { - alertEl.classList.add('hidden'); - alertEl.style.display = 'none'; - }, 5000); - } -} diff --git a/frontend/src/lib/cybersigil.test.ts b/frontend/src/lib/cybersigil.test.ts new file mode 100644 index 0000000..df3cb89 --- /dev/null +++ b/frontend/src/lib/cybersigil.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest'; +import { buildCybersigil, GLYPHS } from './cybersigil'; + +/** Deterministic LCG so a given seed reproduces a given sigil. */ +function seeded(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +describe('buildCybersigil', () => { + it('is deterministic for a fixed RNG', () => { + const a = buildCybersigil({ rng: seeded(42) }); + const b = buildCybersigil({ rng: seeded(42) }); + expect(a).toBe(b); + }); + + it('varies with the seed', () => { + expect(buildCybersigil({ rng: seeded(1) })).not.toBe(buildCybersigil({ rng: seeded(2) })); + }); + + it('emits a single well-formed root svg', () => { + const svg = buildCybersigil({ rng: seeded(7) }); + expect(svg.startsWith('')).toBe(true); + expect(svg.match(/ { + const svg = buildCybersigil({ rng: seeded(7) }); + expect(svg.match(/class="cs-sig-half"/g)).toHaveLength(2); + expect(svg).toContain('transform="scale(-1 1)"'); + }); + + it('every stroke is pathLength-normalised for the carve animation', () => { + const svg = buildCybersigil({ rng: seeded(99) }); + const paths = svg.match(/]*>/g) ?? []; + expect(paths.length).toBeGreaterThan(0); + for (const p of paths) expect(p).toContain('pathLength="1"'); + }); + + it('exposes a numeric, positive viewBox', () => { + const svg = buildCybersigil({ rng: seeded(7) }); + const vb = svg.match(/viewBox="([^"]+)"/)?.[1] ?? ''; + const nums = vb.split(/\s+/).map(Number); + expect(nums).toHaveLength(4); + expect(nums.every(Number.isFinite)).toBe(true); + expect(nums[2]).toBeGreaterThan(0); // width + expect(nums[3]).toBeGreaterThan(0); // height + }); + + it('honours the count option and stays bounded', () => { + const sparse = buildCybersigil({ count: 1, rng: seeded(5) }); + const dense = buildCybersigil({ count: 9, rng: seeded(5) }); + const n = (s: string) => (s.match(/ { + expect(GLYPHS.length).toBeGreaterThan(0); + for (const g of GLYPHS) { + expect(g.w).toBeGreaterThan(0); + expect(g.h).toBeGreaterThan(0); + expect(g.d).toMatch(/^M/); + } + }); +}); diff --git a/frontend/src/pages/api/[...path].ts b/frontend/src/pages/api/[...path].ts index 2a68c5a..0396f72 100644 --- a/frontend/src/pages/api/[...path].ts +++ b/frontend/src/pages/api/[...path].ts @@ -38,7 +38,7 @@ export const ALL: APIRoute = async ({ request, params }) => { if (request.method !== 'GET' && request.method !== 'HEAD' && request.body) { fetchOptions.body = request.body; - // @ts-ignore — required by Node fetch when body is a stream + // @ts-expect-error — required by Node fetch when body is a stream fetchOptions.duplex = 'half'; } @@ -52,7 +52,7 @@ export const ALL: APIRoute = async ({ request, params }) => { if (FORBIDDEN_RESPONSE_HEADERS.has(k)) return; responseHeaders.set(key, value); }); - // @ts-ignore — getSetCookie is on Node fetch's Headers + // @ts-expect-error — getSetCookie is on Node fetch's Headers const setCookies: string[] = response.headers.getSetCookie?.() ?? []; for (const c of setCookies) { responseHeaders.append('set-cookie', c); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..4f78a22 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +// Pure-logic unit tests only (no Astro/DOM). Component/integration tests, if +// added later, should switch to `getViteConfig` from 'astro/config'. +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, +});