added testing
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
edition = "2024"
|
||||
max_width = 100
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
|
||||
+129
-41
@@ -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<String, AppError> {
|
||||
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<CoverImage> {
|
||||
/// bytes via `imagesize::size`, off the runtime via `spawn_blocking`.
|
||||
async fn compute_dim_from_url(state: &AppState, url: &str) -> Option<ImageDim> {
|
||||
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 ( in the markdown body).".to_string(),
|
||||
"A gallery entry must include at least one image ( 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<String> = extract_images(&body)
|
||||
.into_iter()
|
||||
.map(|(_, u)| u)
|
||||
.collect();
|
||||
let image_urls: Vec<String> = 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\
|
||||
\n\
|
||||
```\n\
|
||||
\n\
|
||||
```\n\
|
||||
";
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
|
||||
+8
-8
@@ -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))
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+617
-1
@@ -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",
|
||||
|
||||
+12
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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('<svg')).toBe(true);
|
||||
expect(svg.endsWith('</svg>')).toBe(true);
|
||||
expect(svg.match(/<svg/g)).toHaveLength(1);
|
||||
// no formatting holes leaked into the path data
|
||||
expect(svg).not.toMatch(/NaN|undefined|Infinity/);
|
||||
});
|
||||
|
||||
it('is vertically mirrored (two halves, one flipped about x=0)', () => {
|
||||
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(/<path\b[^>]*>/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(/<path/g) ?? []).length;
|
||||
expect(n(dense)).toBeGreaterThan(n(sparse));
|
||||
expect(n(dense)).toBeLessThanOrEqual(260); // MAX_PATHS(110) mirrored + ornaments
|
||||
});
|
||||
|
||||
it('ships a non-empty glyph library with valid path data', () => {
|
||||
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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user