Compare commits

...

2 Commits

Author SHA1 Message Date
nvrl ac99cc724a split into posts 2026-05-16 23:52:20 +02:00
nvrl f1d5c4a4fd added testing 2026-05-16 23:48:57 +02:00
20 changed files with 1382 additions and 445 deletions
+51
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
edition = "2024"
max_width = 100
+5 -1
View File
@@ -7,6 +7,7 @@ use tracing::error;
use crate::models::ErrorResponse; use crate::models::ErrorResponse;
#[derive(Debug)]
pub enum AppError { pub enum AppError {
Unauthorized, Unauthorized,
NotFound(String), NotFound(String),
@@ -29,7 +30,10 @@ impl IntoResponse for AppError {
} else { } else {
error!("Internal error: {}", msg); error!("Internal error: {}", msg);
} }
(StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string()) (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal error".to_string(),
)
} }
}; };
+30 -10
View File
@@ -35,16 +35,36 @@ pub async fn update_config(
.and_then(|c| serde_json::from_str(&c).ok()) .and_then(|c| serde_json::from_str(&c).ok())
.unwrap_or_default(); .unwrap_or_default();
if let Some(v) = patch.title { config.title = v; } if let Some(v) = patch.title {
if let Some(v) = patch.subtitle { config.subtitle = v; } config.title = 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.subtitle {
if let Some(v) = patch.footer { config.footer = v; } config.subtitle = v;
if let Some(v) = patch.favicon { config.favicon = v; } }
if let Some(v) = patch.theme { config.theme = v; } if let Some(v) = patch.welcome_title {
if let Some(v) = patch.custom_css { config.custom_css = v; } config.welcome_title = 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.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| { let config_str = serde_json::to_string_pretty(&config).map_err(|e| {
error!("Serialization error: {}", e); error!("Serialization error: {}", e);
+10 -9
View File
@@ -123,7 +123,10 @@ pub async fn submit_contact(
if name.as_ref().is_some_and(|s| s.chars().count() > MAX_NAME) { if name.as_ref().is_some_and(|s| s.chars().count() > MAX_NAME) {
return Err(AppError::BadRequest("Name is too long.".into())); 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())); return Err(AppError::BadRequest("Email is too long.".into()));
} }
if subject if subject
@@ -160,9 +163,8 @@ pub async fn submit_contact(
AppError::Internal("Storage error".into(), Some(e.to_string())) AppError::Internal("Storage error".into(), Some(e.to_string()))
})?; })?;
let path = messages_dir.join(format!("{}.json", id)); let path = messages_dir.join(format!("{}.json", id));
let json = serde_json::to_string_pretty(&msg).map_err(|e| { let json = serde_json::to_string_pretty(&msg)
AppError::Internal("Serialization error".into(), Some(e.to_string())) .map_err(|e| AppError::Internal("Serialization error".into(), Some(e.to_string())))?;
})?;
fs::write(&path, json).await.map_err(|e| { fs::write(&path, json).await.map_err(|e| {
error!("Failed to write message {}: {}", id, e); error!("Failed to write message {}: {}", id, e);
AppError::Internal("Storage error".into(), Some(e.to_string())) AppError::Internal("Storage error".into(), Some(e.to_string()))
@@ -189,8 +191,7 @@ pub async fn list_messages(
Ok(e) => e, Ok(e) => e,
Err(e) => { Err(e) => {
error!("Failed to read messages dir: {}", e); error!("Failed to read messages dir: {}", e);
return AppError::Internal("Read error".into(), Some(e.to_string())) return AppError::Internal("Read error".into(), Some(e.to_string())).into_response();
.into_response();
} }
}; };
@@ -239,8 +240,8 @@ pub async fn delete_message(
if !fs::try_exists(&path).await.unwrap_or(false) { if !fs::try_exists(&path).await.unwrap_or(false) {
return Err(AppError::NotFound("Message not found.".into())); return Err(AppError::NotFound("Message not found.".into()));
} }
fs::remove_file(&path).await.map_err(|e| { fs::remove_file(&path)
AppError::Internal("Delete failed".into(), Some(e.to_string())) .await
})?; .map_err(|e| AppError::Internal("Delete failed".into(), Some(e.to_string())))?;
Ok(Json(ContactResponse { ok: true })) Ok(Json(ContactResponse { ok: true }))
} }
+24 -366
View File
@@ -1,356 +1,36 @@
//! HTTP handlers for posts. Orchestration only — parsing, image handling,
//! and the cache live in [`crate::post`].
use axum::{ use axum::{
Json, Json,
extract::{Path, State}, extract::{Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
}; };
use chrono::Utc; use chrono::Utc;
use std::{collections::HashMap, sync::Arc}; use std::sync::Arc;
use tokio::fs; use tokio::fs;
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::post::cache::{neighbors_from_cache, rebuild_posts_cache};
use crate::post::images::{cover_from, dims_for_urls, extract_images};
use crate::post::parse::{reading_time, serialize_post, validate_slug};
use crate::{ use crate::{
AppState, CachedPost, AppState,
auth::is_authed, auth::is_authed,
error::AppError, error::AppError,
models::{ models::{CreatePostRequest, PostDetail, PostInfo, PostMeta},
CoverImage, CreatePostRequest, ImageDim, PostDetail, PostInfo, PostMeta, PostNeighbor,
},
}; };
const WORDS_PER_MINUTE: u32 = 200; async fn write_post_atomic(state: &AppState, slug: &str, contents: &str) -> Result<(), AppError> {
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",
];
fn validate_slug(s: &str) -> Result<(), AppError> {
if s.is_empty() {
return Err(AppError::BadRequest("Slug is empty".to_string()));
}
if s.len() > MAX_SLUG_LEN {
return Err(AppError::BadRequest(format!(
"Slug exceeds {} characters",
MAX_SLUG_LEN
)));
}
if s.starts_with('.') {
return Err(AppError::BadRequest(
"Slug cannot start with '.'".to_string(),
));
}
if s.ends_with('.') || s.ends_with(' ') {
return Err(AppError::BadRequest(
"Slug cannot end with '.' or space".to_string(),
));
}
if s.contains("..") {
return Err(AppError::BadRequest(
"Slug cannot contain '..'".to_string(),
));
}
for c in s.chars() {
if c.is_control() {
return Err(AppError::BadRequest(
"Slug contains control characters".to_string(),
));
}
if matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') {
return Err(AppError::BadRequest(format!(
"Slug contains invalid character '{}'",
c
)));
}
}
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(),
));
}
Ok(())
}
fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
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
+ raw[end_marker..]
.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');
Some((yaml, body))
}
fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> {
let (yaml, body) = split_frontmatter(raw).ok_or_else(|| {
AppError::Internal(
"Missing frontmatter".to_string(),
Some("post is missing the YAML --- block".to_string()),
)
})?;
let meta: PostMeta = serde_yaml::from_str(yaml).map_err(|e| {
AppError::Internal(
"Invalid frontmatter".to_string(),
Some(format!("YAML parse error: {}", e)),
)
})?;
Ok((meta, body.to_string()))
}
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()),
)
})?;
Ok(format!("---\n{}---\n{}", yaml, body))
}
fn reading_time(body: &str) -> u32 {
let words = body.split_whitespace().count() as u32;
(words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1)
}
/// Scan markdown for `![alt](url)` images. Returns (alt, url) pairs in order.
/// Skips inside fenced code blocks. Tolerates titles like `![alt](url "title")`.
fn extract_images(body: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut in_fence = false;
for line in body.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let bytes = line.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'!' && bytes[i + 1] == b'[' {
if let Some(rel_close) = line[i + 2..].find(']') {
let close = i + 2 + rel_close;
if close + 1 < line.len() && bytes[close + 1] == b'(' {
if let Some(rel_paren) = line[close + 2..].find(')') {
let paren_end = close + 2 + rel_paren;
let alt = line[i + 2..close].to_string();
let url_field = line[close + 2..paren_end].trim();
let url = url_field
.split_once(|c: char| c.is_whitespace())
.map(|(u, _)| u)
.unwrap_or(url_field)
.trim_matches(|c| c == '<' || c == '>')
.to_string();
if !url.is_empty() {
out.push((alt, url));
}
i = paren_end + 1;
continue;
}
}
}
}
i += 1;
}
}
out
}
fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
images.first().map(|(alt, url)| CoverImage {
url: url.clone(),
alt: alt.clone(),
w: None,
h: None,
})
}
/// Probe an uploads-relative URL for image dimensions. Reads only header
/// 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('/')
{
return None;
}
let path = state.data_dir.join("uploads").join(name);
tokio::task::spawn_blocking(move || imagesize::size(&path).ok())
.await
.ok()
.flatten()
.map(|s| ImageDim {
w: s.width as u32,
h: s.height as u32,
})
}
/// Returns cached dim if present, else probes the file and caches the result.
async fn dim_for_url(state: &AppState, url: &str) -> Option<ImageDim> {
{
let cache = state.image_dims_cache.read().await;
if let Some(d) = cache.get(url) {
return Some(*d);
}
}
let d = compute_dim_from_url(state, url).await?;
state
.image_dims_cache
.write()
.await
.insert(url.to_string(), d);
Some(d)
}
/// Returns a map of `url -> ImageDim` for the given URLs, using the cache
/// and probing only the URLs that aren't cached yet.
async fn dims_for_urls(state: &AppState, urls: &[String]) -> HashMap<String, ImageDim> {
let mut out: HashMap<String, ImageDim> = HashMap::new();
let mut missing: Vec<String> = Vec::new();
{
let cache = state.image_dims_cache.read().await;
for url in urls {
if out.contains_key(url) {
continue;
}
if let Some(d) = cache.get(url) {
out.insert(url.clone(), *d);
} else {
missing.push(url.clone());
}
}
}
if missing.is_empty() {
return out;
}
let mut newly: Vec<(String, ImageDim)> = Vec::new();
for url in &missing {
if let Some(d) = compute_dim_from_url(state, url).await {
newly.push((url.clone(), d));
}
}
if !newly.is_empty() {
let mut cache = state.image_dims_cache.write().await;
for (url, d) in &newly {
cache.insert(url.clone(), *d);
out.insert(url.clone(), *d);
}
}
out
}
fn excerpt_from(meta: &PostMeta, body: &str) -> String {
if let Some(s) = meta.summary.as_ref() {
if !s.trim().is_empty() {
return s.trim().to_string();
}
}
let plain = body
.replace(['#', '*', '_', '`'], "")
.replace('\n', " ");
let mut out: String = plain.chars().take(200).collect();
if plain.chars().count() > 200 {
out.push_str("...");
}
out.trim().to_string()
}
fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo {
let images = extract_images(body);
PostInfo {
slug: slug.to_string(),
date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(body),
excerpt: excerpt_from(meta, body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
}
}
/// Scans the posts directory and replaces the in-memory cache.
/// Called at startup and after any mutation (create/rename/delete).
pub async fn rebuild_posts_cache(state: &AppState) {
let posts_dir = state.data_dir.join("posts");
let mut posts: Vec<CachedPost> = Vec::new();
let mut rd = match fs::read_dir(&posts_dir).await {
Ok(rd) => rd,
Err(_) => {
*state.posts_cache.write().await = posts;
return;
}
};
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path).await else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
let mut info = build_post_info(slug, &meta, &body);
if let Some(cover) = info.cover_image.as_mut() {
if let Some(d) = dim_for_url(state, &cover.url).await {
cover.w = Some(d.w);
cover.h = Some(d.h);
}
}
posts.push(CachedPost { info, body });
}
Ok(None) => break,
Err(e) => {
warn!("Error iterating posts dir: {}", e);
break;
}
}
}
posts.sort_by(|a, b| {
b.info
.date
.cmp(&a.info.date)
.then_with(|| a.info.slug.cmp(&b.info.slug))
});
*state.posts_cache.write().await = posts;
}
async fn write_post_atomic(
state: &AppState,
slug: &str,
contents: &str,
) -> Result<(), AppError> {
let _guard = state.post_lock.lock().await; let _guard = state.post_lock.lock().await;
let final_path = state.data_dir.join("posts").join(format!("{}.md", slug)); 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)); let tmp_path = state
fs::write(&tmp_path, contents).await.map_err(|e| { .data_dir
AppError::Internal("Write error".to_string(), Some(e.to_string())) .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 { if let Err(e) = fs::rename(&tmp_path, &final_path).await {
let _ = fs::remove_file(&tmp_path).await; let _ = fs::remove_file(&tmp_path).await;
return Err(AppError::Internal( return Err(AppError::Internal(
@@ -407,13 +87,17 @@ pub async fn create_post(
let images = extract_images(&payload.content); let images = extract_images(&payload.content);
if images.is_empty() { if images.is_empty() {
return Err(AppError::BadRequest( 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 { let meta = PostMeta {
date: payload.date.unwrap_or_else(|| Utc::now().date_naive()), 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()), summary: payload.summary.filter(|s| !s.trim().is_empty()),
tags: payload.tags, tags: payload.tags,
draft: payload.draft, draft: payload.draft,
@@ -497,29 +181,6 @@ pub async fn list_posts(
Json(posts) Json(posts)
} }
async fn neighbors_from_cache(
state: &AppState,
slug: &str,
admin: bool,
) -> (Option<PostNeighbor>, Option<PostNeighbor>) {
let cache = state.posts_cache.read().await;
let visible: Vec<&PostInfo> = cache
.iter()
.filter(|p| admin || !p.info.draft)
.map(|p| &p.info)
.collect();
let Some(i) = visible.iter().position(|p| p.slug == slug) else {
return (None, None);
};
let to_neighbor = |p: &PostInfo| PostNeighbor {
slug: p.slug.clone(),
title: p.title.clone(),
};
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)
}
pub async fn get_post( pub async fn get_post(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
headers: HeaderMap, headers: HeaderMap,
@@ -541,10 +202,7 @@ pub async fn get_post(
let (prev, next) = neighbors_from_cache(&state, &slug, admin).await; let (prev, next) = neighbors_from_cache(&state, &slug, admin).await;
let image_urls: Vec<String> = extract_images(&body) let image_urls: Vec<String> = extract_images(&body).into_iter().map(|(_, u)| u).collect();
.into_iter()
.map(|(_, u)| u)
.collect();
let dimensions = dims_for_urls(&state, &image_urls).await; let dimensions = dims_for_urls(&state, &image_urls).await;
Ok(Json(PostDetail { Ok(Json(PostDetail {
+8 -10
View File
@@ -23,10 +23,8 @@ pub struct UploadQuery {
/// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent — /// Allowed upload extensions. SVG, HTML, JS, executables intentionally absent —
/// /uploads/* is served as-is, so any active content there is XSS waiting to happen. /// /uploads/* is served as-is, so any active content there is XSS waiting to happen.
const ALLOWED_EXTS: &[&str] = &[ const ALLOWED_EXTS: &[&str] = &[
"jpg", "jpeg", "png", "webp", "gif", "avif", "jpg", "jpeg", "png", "webp", "gif", "avif", "pdf", "txt", "md", "mp3", "wav", "ogg", "mp4",
"pdf", "txt", "md", "webm", "mov",
"mp3", "wav", "ogg",
"mp4", "webm", "mov",
]; ];
fn validate_filename(name: &str) -> Result<(), AppError> { 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 uploads_dir = state.data_dir.join("uploads");
let file_path = uploads_dir.join(&filename); let file_path = uploads_dir.join(&filename);
let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| { let canonical_dir = fs::canonicalize(&uploads_dir)
AppError::Internal("Path resolution".to_string(), Some(e.to_string())) .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 let Ok(canonical_file) = fs::canonicalize(&file_path).await {
if !canonical_file.starts_with(&canonical_dir) { if !canonical_file.starts_with(&canonical_dir) {
warn!("Refused delete outside uploads dir: {}", filename); warn!("Refused delete outside uploads dir: {}", filename);
@@ -209,9 +207,9 @@ pub async fn upload_file(
}; };
// Final containment check. // Final containment check.
let canonical_dir = fs::canonicalize(&uploads_dir).await.map_err(|e| { let canonical_dir = fs::canonicalize(&uploads_dir)
AppError::Internal("Path resolution".to_string(), Some(e.to_string())) .await
})?; .map_err(|e| AppError::Internal("Path resolution".to_string(), Some(e.to_string())))?;
if let Some(parent) = final_path.parent() { if let Some(parent) = final_path.parent() {
let canonical_parent = fs::canonicalize(parent).await.map_err(|e| { let canonical_parent = fs::canonicalize(parent).await.map_err(|e| {
AppError::Internal("Path resolution".to_string(), Some(e.to_string())) AppError::Internal("Path resolution".to_string(), Some(e.to_string()))
+10 -9
View File
@@ -2,6 +2,7 @@ pub mod auth;
pub mod error; pub mod error;
pub mod handlers; pub mod handlers;
pub mod models; pub mod models;
pub mod post;
use axum::{ use axum::{
Router, Router,
@@ -47,9 +48,7 @@ async fn main() {
.filter(|t| !t.trim().is_empty()) .filter(|t| !t.trim().is_empty())
.expect("ADMIN_TOKEN must be set to a non-empty value"); .expect("ADMIN_TOKEN must be set to a non-empty value");
if admin_token.len() < 16 { if admin_token.len() < 16 {
warn!( warn!("ADMIN_TOKEN is shorter than 16 characters. Use a long random string in production.");
"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_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string());
let data_dir = PathBuf::from(data_dir_str); let data_dir = PathBuf::from(data_dir_str);
@@ -78,8 +77,11 @@ async fn main() {
contact_rate_limit: Mutex::new(HashMap::new()), contact_rate_limit: Mutex::new(HashMap::new()),
}); });
handlers::posts::rebuild_posts_cache(&state).await; post::cache::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()); spawn_rate_limit_reaper(state.clone());
@@ -89,8 +91,8 @@ async fn main() {
// server-to-server and not subject to CORS. // server-to-server and not subject to CORS.
let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) { let cors = match env::var("FRONTEND_ORIGIN").ok().filter(|s| !s.is_empty()) {
Some(origin) => { Some(origin) => {
let value = HeaderValue::from_str(&origin) let value =
.expect("FRONTEND_ORIGIN must be a valid origin URL"); HeaderValue::from_str(&origin).expect("FRONTEND_ORIGIN must be a valid origin URL");
CorsLayer::new() CorsLayer::new()
.allow_origin(AllowOrigin::exact(value)) .allow_origin(AllowOrigin::exact(value))
.allow_methods([ .allow_methods([
@@ -132,8 +134,7 @@ async fn main() {
) )
.route( .route(
"/api/upload", "/api/upload",
post(handlers::upload::upload_file) post(handlers::upload::upload_file).layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)),
.layer(DefaultBodyLimit::max(UPLOAD_BODY_LIMIT)),
) )
.route("/api/contact", post(handlers::contact::submit_contact)) .route("/api/contact", post(handlers::contact::submit_contact))
.route("/api/messages", get(handlers::contact::list_messages)) .route("/api/messages", get(handlers::contact::list_messages))
+2 -3
View File
@@ -31,9 +31,8 @@ impl Default for SiteConfig {
title: "Ela's Atelier".to_string(), title: "Ela's Atelier".to_string(),
subtitle: "Works on paper, canvas, and elsewhere".to_string(), subtitle: "Works on paper, canvas, and elsewhere".to_string(),
welcome_title: "Works on view".to_string(), welcome_title: "Works on view".to_string(),
welcome_subtitle: welcome_subtitle: "An ongoing arrangement of pieces, sketches, and stray observations."
"An ongoing arrangement of pieces, sketches, and stray observations." .to_string(),
.to_string(),
footer: "Hand-arranged with care".to_string(), footer: "Hand-arranged with care".to_string(),
favicon: "/favicon.svg".to_string(), favicon: "/favicon.svg".to_string(),
theme: "salon".to_string(), theme: "salon".to_string(),
+113
View File
@@ -0,0 +1,113 @@
//! The in-memory posts cache: rebuilt from disk at startup and after every
//! mutation, plus prev/next neighbour lookup over the visible set.
use tokio::fs;
use tracing::warn;
use crate::models::{PostInfo, PostMeta, PostNeighbor};
use crate::post::images::{cover_from, dim_for_url, extract_images};
use crate::post::parse::{excerpt_from, parse_post, reading_time};
use crate::{AppState, CachedPost};
fn build_post_info(slug: &str, meta: &PostMeta, body: &str) -> PostInfo {
let images = extract_images(body);
PostInfo {
slug: slug.to_string(),
date: meta.date,
title: meta.title.clone(),
summary: meta.summary.clone(),
tags: meta.tags.clone(),
draft: meta.draft,
reading_time: reading_time(body),
excerpt: excerpt_from(meta, body),
cover_image: cover_from(&images),
image_count: images.len() as u32,
}
}
/// Scans the posts directory and replaces the in-memory cache.
/// Called at startup and after any mutation (create/rename/delete).
pub(crate) async fn rebuild_posts_cache(state: &AppState) {
let posts_dir = state.data_dir.join("posts");
let mut posts: Vec<CachedPost> = Vec::new();
let mut rd = match fs::read_dir(&posts_dir).await {
Ok(rd) => rd,
Err(_) => {
*state.posts_cache.write().await = posts;
return;
}
};
loop {
match rd.next_entry().await {
Ok(Some(entry)) => {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Some(slug) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if slug.starts_with('.') {
continue;
}
let Ok(raw) = fs::read_to_string(&path).await else {
continue;
};
let Ok((meta, body)) = parse_post(&raw) else {
warn!("Skipping post with bad frontmatter: {}", slug);
continue;
};
let mut info = build_post_info(slug, &meta, &body);
if let Some(cover) = info.cover_image.as_mut() {
if let Some(d) = dim_for_url(state, &cover.url).await {
cover.w = Some(d.w);
cover.h = Some(d.h);
}
}
posts.push(CachedPost { info, body });
}
Ok(None) => break,
Err(e) => {
warn!("Error iterating posts dir: {}", e);
break;
}
}
}
posts.sort_by(|a, b| {
b.info
.date
.cmp(&a.info.date)
.then_with(|| a.info.slug.cmp(&b.info.slug))
});
*state.posts_cache.write().await = posts;
}
pub(crate) async fn neighbors_from_cache(
state: &AppState,
slug: &str,
admin: bool,
) -> (Option<PostNeighbor>, Option<PostNeighbor>) {
let cache = state.posts_cache.read().await;
let visible: Vec<&PostInfo> = cache
.iter()
.filter(|p| admin || !p.info.draft)
.map(|p| &p.info)
.collect();
let Some(i) = visible.iter().position(|p| p.slug == slug) else {
return (None, None);
};
let to_neighbor = |p: &PostInfo| PostNeighbor {
slug: p.slug.clone(),
title: p.title.clone(),
};
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)
}
+166
View File
@@ -0,0 +1,166 @@
//! Markdown image extraction, cover selection, and the on-disk
//! image-dimension probe (header-only read, cached on `AppState`).
use std::collections::HashMap;
use crate::AppState;
use crate::models::{CoverImage, ImageDim};
/// Scan markdown for `![alt](url)` images. Returns (alt, url) pairs in order.
/// Skips inside fenced code blocks. Tolerates titles like `![alt](url "title")`.
pub(crate) fn extract_images(body: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
let mut in_fence = false;
for line in body.lines() {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let bytes = line.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'!' && bytes[i + 1] == b'[' {
if let Some(rel_close) = line[i + 2..].find(']') {
let close = i + 2 + rel_close;
if close + 1 < line.len() && bytes[close + 1] == b'(' {
if let Some(rel_paren) = line[close + 2..].find(')') {
let paren_end = close + 2 + rel_paren;
let alt = line[i + 2..close].to_string();
let url_field = line[close + 2..paren_end].trim();
let url = url_field
.split_once(|c: char| c.is_whitespace())
.map(|(u, _)| u)
.unwrap_or(url_field)
.trim_matches(|c| c == '<' || c == '>')
.to_string();
if !url.is_empty() {
out.push((alt, url));
}
i = paren_end + 1;
continue;
}
}
}
}
i += 1;
}
}
out
}
pub(crate) fn cover_from(images: &[(String, String)]) -> Option<CoverImage> {
images.first().map(|(alt, url)| CoverImage {
url: url.clone(),
alt: alt.clone(),
w: None,
h: None,
})
}
/// Probe an uploads-relative URL for image dimensions. Reads only header
/// 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('/') {
return None;
}
let path = state.data_dir.join("uploads").join(name);
tokio::task::spawn_blocking(move || imagesize::size(&path).ok())
.await
.ok()
.flatten()
.map(|s| ImageDim {
w: s.width as u32,
h: s.height as u32,
})
}
/// Returns cached dim if present, else probes the file and caches the result.
pub(crate) async fn dim_for_url(state: &AppState, url: &str) -> Option<ImageDim> {
{
let cache = state.image_dims_cache.read().await;
if let Some(d) = cache.get(url) {
return Some(*d);
}
}
let d = compute_dim_from_url(state, url).await?;
state
.image_dims_cache
.write()
.await
.insert(url.to_string(), d);
Some(d)
}
/// Returns a map of `url -> ImageDim` for the given URLs, using the cache
/// and probing only the URLs that aren't cached yet.
pub(crate) async fn dims_for_urls(state: &AppState, urls: &[String]) -> HashMap<String, ImageDim> {
let mut out: HashMap<String, ImageDim> = HashMap::new();
let mut missing: Vec<String> = Vec::new();
{
let cache = state.image_dims_cache.read().await;
for url in urls {
if out.contains_key(url) {
continue;
}
if let Some(d) = cache.get(url) {
out.insert(url.clone(), *d);
} else {
missing.push(url.clone());
}
}
}
if missing.is_empty() {
return out;
}
let mut newly: Vec<(String, ImageDim)> = Vec::new();
for url in &missing {
if let Some(d) = compute_dim_from_url(state, url).await {
newly.push((url.clone(), d));
}
}
if !newly.is_empty() {
let mut cache = state.image_dims_cache.write().await;
for (url, d) in &newly {
cache.insert(url.clone(), *d);
out.insert(url.clone(), *d);
}
}
out
}
#[cfg(test)]
mod tests {
use super::{cover_from, extract_images};
#[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");
}
}
+13
View File
@@ -0,0 +1,13 @@
//! Post domain logic, split out of the HTTP layer.
//!
//! - [`parse`] — slug validation, frontmatter split/parse/serialize, reading
//! time, excerpt. Pure, no I/O.
//! - [`images`] — markdown image extraction, cover selection, and the
//! filesystem image-dimension probe + cache.
//! - [`cache`] — the in-memory posts cache (rebuild + neighbour lookup).
//!
//! `handlers::posts` stays thin and only orchestrates these.
pub mod cache;
pub mod images;
pub mod parse;
+179
View File
@@ -0,0 +1,179 @@
//! Pure post parsing: slug validation, YAML frontmatter, reading time,
//! excerpt. No filesystem or network access — trivially unit-testable.
use crate::error::AppError;
use crate::models::PostMeta;
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",
];
pub(crate) fn validate_slug(s: &str) -> Result<(), AppError> {
if s.is_empty() {
return Err(AppError::BadRequest("Slug is empty".to_string()));
}
if s.len() > MAX_SLUG_LEN {
return Err(AppError::BadRequest(format!(
"Slug exceeds {} characters",
MAX_SLUG_LEN
)));
}
if s.starts_with('.') {
return Err(AppError::BadRequest(
"Slug cannot start with '.'".to_string(),
));
}
if s.ends_with('.') || s.ends_with(' ') {
return Err(AppError::BadRequest(
"Slug cannot end with '.' or space".to_string(),
));
}
if s.contains("..") {
return Err(AppError::BadRequest("Slug cannot contain '..'".to_string()));
}
for c in s.chars() {
if c.is_control() {
return Err(AppError::BadRequest(
"Slug contains control characters".to_string(),
));
}
if matches!(c, '/' | '\\' | '<' | '>' | ':' | '"' | '|' | '?' | '*') {
return Err(AppError::BadRequest(format!(
"Slug contains invalid character '{}'",
c
)));
}
}
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()));
}
Ok(())
}
pub(crate) fn split_frontmatter(raw: &str) -> Option<(&str, &str)> {
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
+ raw[end_marker..]
.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');
Some((yaml, body))
}
pub(crate) fn parse_post(raw: &str) -> Result<(PostMeta, String), AppError> {
let (yaml, body) = split_frontmatter(raw).ok_or_else(|| {
AppError::Internal(
"Missing frontmatter".to_string(),
Some("post is missing the YAML --- block".to_string()),
)
})?;
let meta: PostMeta = serde_yaml::from_str(yaml).map_err(|e| {
AppError::Internal(
"Invalid frontmatter".to_string(),
Some(format!("YAML parse error: {}", e)),
)
})?;
Ok((meta, body.to_string()))
}
pub(crate) 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())))?;
Ok(format!("---\n{}---\n{}", yaml, body))
}
pub(crate) fn reading_time(body: &str) -> u32 {
let words = body.split_whitespace().count() as u32;
(words + WORDS_PER_MINUTE - 1) / WORDS_PER_MINUTE.max(1)
}
pub(crate) fn excerpt_from(meta: &PostMeta, body: &str) -> String {
if let Some(s) = meta.summary.as_ref() {
if !s.trim().is_empty() {
return s.trim().to_string();
}
}
let plain = body.replace(['#', '*', '_', '`'], "").replace('\n', " ");
let mut out: String = plain.chars().take(200).collect();
if plain.chars().count() > 200 {
out.push_str("...");
}
out.trim().to_string()
}
#[cfg(test)]
mod tests {
use super::{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);
}
}
+56
View File
@@ -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"
}
}
}
}
+617 -1
View File
@@ -41,11 +41,13 @@
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.9", "@astrojs/check": "^0.9.9",
"@biomejs/biome": "^2.0.0",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^3.0.0"
}, },
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
@@ -608,6 +610,181 @@
"node": ">=6.9.0" "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": { "node_modules/@bramus/specificity": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
@@ -3305,6 +3482,17 @@
"@babel/types": "^7.28.2" "@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": { "node_modules/@types/debug": {
"version": "4.1.13", "version": "4.1.13",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
@@ -3314,6 +3502,13 @@
"@types/ms": "*" "@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": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "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" "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": { "node_modules/@volar/kit": {
"version": "2.4.28", "version": "2.4.28",
"resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz",
@@ -3642,6 +3962,16 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/astro": {
"version": "6.0.8", "version": "6.0.8",
"resolved": "https://registry.npmjs.org/astro/-/astro-6.0.8.tgz", "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": "^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": { "node_modules/caniuse-lite": {
"version": "1.0.30001781", "version": "1.0.30001781",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
@@ -3829,6 +4169,23 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/character-entities": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
@@ -3859,6 +4216,16 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -4160,6 +4527,16 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/defu": {
"version": "6.1.4", "version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
@@ -4483,6 +4860,16 @@
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT" "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": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -5436,6 +5823,13 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/lru-cache": {
"version": "11.2.7", "version": "11.2.7",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz",
@@ -6580,6 +6974,23 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/piccolore": {
"version": "0.1.3", "version": "0.1.3",
"resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz",
@@ -7172,6 +7583,13 @@
"node": ">=20" "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": { "node_modules/sisteransi": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -7209,6 +7627,13 @@
"url": "https://github.com/sponsors/wooorm" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -7218,6 +7643,13 @@
"node": ">= 0.8" "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": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -7260,6 +7692,26 @@
"node": ">=8" "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": { "node_modules/style-mod": {
"version": "4.1.3", "version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
@@ -7322,6 +7774,13 @@
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==", "integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT" "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": { "node_modules/tinyclip": {
"version": "0.1.12", "version": "0.1.12",
"resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz",
@@ -7356,6 +7815,36 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tldts": {
"version": "7.0.30", "version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", "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": { "node_modules/vitefu": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", "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": { "node_modules/volar-service-css": {
"version": "0.0.70", "version": "0.0.70",
"resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz",
@@ -8257,6 +8856,23 @@
"node": ">=4" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+12 -2
View File
@@ -9,7 +9,15 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "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": { "dependencies": {
"@astrojs/node": "^10.0.3", "@astrojs/node": "^10.0.3",
@@ -45,10 +53,12 @@
}, },
"devDependencies": { "devDependencies": {
"@astrojs/check": "^0.9.9", "@astrojs/check": "^0.9.9",
"@biomejs/biome": "^2.0.0",
"@types/katex": "^0.16.8", "@types/katex": "^0.16.8",
"@types/node": "^25.5.0", "@types/node": "^25.5.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"typescript": "^5.9.3" "typescript": "^5.9.3",
"vitest": "^3.0.0"
} }
} }
-32
View File
@@ -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);
}
}
+72
View File
@@ -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/);
}
});
});
+2 -2
View File
@@ -38,7 +38,7 @@ export const ALL: APIRoute = async ({ request, params }) => {
if (request.method !== 'GET' && request.method !== 'HEAD' && request.body) { if (request.method !== 'GET' && request.method !== 'HEAD' && request.body) {
fetchOptions.body = 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'; fetchOptions.duplex = 'half';
} }
@@ -52,7 +52,7 @@ export const ALL: APIRoute = async ({ request, params }) => {
if (FORBIDDEN_RESPONSE_HEADERS.has(k)) return; if (FORBIDDEN_RESPONSE_HEADERS.has(k)) return;
responseHeaders.set(key, value); 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?.() ?? []; const setCookies: string[] = response.headers.getSetCookie?.() ?? [];
for (const c of setCookies) { for (const c of setCookies) {
responseHeaders.append('set-cookie', c); responseHeaders.append('set-cookie', c);
+10
View File
@@ -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'],
},
});