diff --git a/.env.example b/.env.example index 2950424..b38199c 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,20 @@ # Backend Configuration PORT=3000 -ADMIN_TOKEN=your_secure_random_token_here + +# REQUIRED. Long random string. Generate with `openssl rand -hex 32`. +# Used as the admin token; stored as an HttpOnly cookie after login. +ADMIN_TOKEN= + DATA_DIR=/app/data +# Cookie hardening. Default is true; set to false for local HTTP development +# only. In production with HTTPS, leave this true. +COOKIE_SECURE=true + +# CORS allow-list. Leave empty unless you expose the backend directly to +# browsers. With the default Astro proxy setup, no CORS is needed. +FRONTEND_ORIGIN= + # Frontend Configuration -# URL of the backend API accessible from the frontend container +# URL of the backend API accessible from the frontend container. PUBLIC_API_URL=http://backend:3000 diff --git a/.gitignore b/.gitignore index c486d3e..8cdb821 100644 --- a/.gitignore +++ b/.gitignore @@ -8,10 +8,8 @@ Thumbs.db # Environment .env -.env.local -.env.development.local -.env.test.local -.env.production.local +.env.* +!.env.example docker-compose.override.yml # Backend (Rust) @@ -27,9 +25,8 @@ frontend/yarn-debug.log* frontend/yarn-error.log* frontend/.pnpm-debug.log* -# Data (Persistent storage) -# We might want to keep the directory structure but ignore the actual content -# data/posts/* -# data/uploads/* -# !data/posts/hello-world.md -# !data/posts/another-post.md +# Persistent runtime data — your blog's content lives here on the deployed +# host, not in the repo. The two seed posts under data/posts/ ship with the +# initial commit; new files created by the editor stay local. +data/uploads/ +data/config.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..40c25ec --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# Narlblog + +A single-author blog. Rust/Axum API backed by markdown files on disk; Astro/React frontend with a glass-effect Catppuccin theme. KaTeX math, GFM tables, server-side syntax highlighting, draft posts, RSS feed. + +``` +backend/ Rust + Axum API (filesystem-backed) +frontend/ Astro + React, SSRs pages and proxies /api/* +data/ Runtime data — posts, uploads, config.json (host volume) +``` + +## Quick start + +```sh +cp .env.example .env +# Generate a strong admin token: +echo "ADMIN_TOKEN=$(openssl rand -hex 32)" >> .env + +docker compose up --build +# → http://localhost:4321 +# → log in at /admin/login with the token from .env +``` + +Make sure the `data/` host directory is owned by UID 1000 (the backend container's app user): + +```sh +sudo chown -R 1000:1000 data/ +``` + +## Environment + +| Variable | Required | Default | Notes | +| ----------------- | -------- | ----------------- | --------------------------------------------------------------------------- | +| `ADMIN_TOKEN` | yes | — | Long random string. Stored as an HttpOnly cookie after login. | +| `PORT` | no | `3000` | Backend port. | +| `DATA_DIR` | no | `/app/data` | Where posts/uploads/config live. | +| `COOKIE_SECURE` | no | `true` | Set `false` only for local HTTP development. | +| `FRONTEND_ORIGIN` | no | _empty_ | Set to your frontend's URL only if you expose the backend directly. | +| `RUST_LOG` | no | `info` | tracing-subscriber filter. | +| `PUBLIC_API_URL` | no | `http://backend:3000` | Backend URL the Astro proxy hits server-to-server. | + +## Local development + +Backend: + +```sh +cd backend +ADMIN_TOKEN=devtoken COOKIE_SECURE=false DATA_DIR=../data cargo run +``` + +Frontend (separate terminal, Node ≥ 22.12): + +```sh +cd frontend +npm install +npm run dev +# → http://localhost:4321 (proxies to PUBLIC_API_URL, default http://backend:3000) +``` + +For a fully local stack, set `PUBLIC_API_URL=http://localhost:3000` in `frontend/.env`. + +## Authoring posts + +Posts are markdown files at `data/posts/.md` with YAML frontmatter: + +```markdown +--- +date: 2026-05-09 +summary: Optional summary; falls back to auto-extracted excerpt. +tags: + - rust + - astro +draft: false +--- +# My post + +Body in **markdown**, with $LaTeX$ via `$…$` / `$$…$$`, GFM tables, and fenced code blocks (```rust, ```ts, …). +``` + +Posts with `draft: true` are hidden from the public list and 404 when accessed by anyone without an admin session. Posts are sorted by `date` descending on the frontpage. + +The web editor at `/admin/editor` writes the same format and updates atomically (write to `.tmp`, rename over target). + +## Uploads + +Allowlisted extensions: jpg, jpeg, png, webp, gif, avif, pdf, txt, md, mp3, wav, ogg, mp4, webm, mov. Magic bytes are checked against the extension. SVG and HTML are intentionally rejected — `/uploads/*` is served as-is, so any active content there would be XSS. + +Max upload size: 50 MB. + +## Backups + +The deployed `data/` directory is the entire blog. Back it up with whatever you trust — `rsync`, restic, borg, a sidecar container; nothing fancy is built in. + +```sh +rsync -av data/ backup-host:/path/to/narlblog-data/ +``` + +## Stack + +- **Backend**: axum 0.8, serde_yaml frontmatter, subtle constant-time auth, infer for upload sniffing +- **Frontend**: Astro 6, React 19, Tailwind 4, marked + marked-katex-extension + marked-highlight + DOMPurify +- **Editor**: CodeMirror 6 with vim mode and asset autocomplete diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..8432dae --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,3 @@ +target/ +.git/ +*.md diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 3750c8c..fad2a19 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -104,9 +104,12 @@ dependencies = [ "axum", "chrono", "dotenvy", + "infer", "serde", "serde_json", + "serde_yaml", "slug", + "subtle", "tokio", "tower-http", "tracing", @@ -125,6 +128,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -141,6 +150,17 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -188,6 +208,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -204,6 +230,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -252,6 +284,12 @@ dependencies = [ "slab", ] +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "http" version = "1.4.0" @@ -363,6 +401,25 @@ dependencies = [ "cc", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "itoa" version = "1.0.18" @@ -664,6 +721,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -727,6 +797,12 @@ version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.117" @@ -922,6 +998,22 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d053696..605f650 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -7,9 +7,12 @@ edition = "2024" axum = { version = "0.8.8", features = ["multipart", "macros"] } chrono = { version = "0.4.44", features = ["serde"] } dotenvy = "0.15.7" +infer = "0.19.0" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" +serde_yaml = "0.9.34" slug = "0.1.6" +subtle = "2.6.1" tokio = { version = "1.50.0", features = ["full"] } tower-http = { version = "0.6.8", features = ["cors", "fs"] } tracing = "0.1.44" diff --git a/backend/Dockerfile b/backend/Dockerfile index c93cacc..931f9ae 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,14 +1,30 @@ -FROM rust:latest as builder +FROM rust:1.83-slim AS builder WORKDIR /usr/src/app -COPY . . +# Cache deps as their own layer. +COPY Cargo.toml Cargo.lock ./ +RUN mkdir -p src \ + && echo "fn main() {}" > src/main.rs \ + && cargo build --release \ + && rm -rf src target/release/deps/backend* target/release/backend* + +# Now build the real source. +COPY src ./src RUN cargo build --release FROM debian:bookworm-slim +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 1000 app \ + && useradd --system --uid 1000 --gid app --home-dir /app app + WORKDIR /app COPY --from=builder /usr/src/app/target/release/backend /usr/local/bin/backend +USER app:app + EXPOSE 3000 CMD ["backend"] diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 5a89fbd..9065ab3 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -1,11 +1,48 @@ use crate::error::AppError; use axum::http::HeaderMap; +use subtle::ConstantTimeEq; use tracing::warn; +const COOKIE_NAME: &str = "admin"; + +fn extract_token(headers: &HeaderMap) -> Option { + if let Some(auth) = headers.get("Authorization").and_then(|h| h.to_str().ok()) { + if let Some(token) = auth.strip_prefix("Bearer ") { + return Some(token.to_string()); + } + } + if let Some(cookie_header) = headers.get("Cookie").and_then(|h| h.to_str().ok()) { + for part in cookie_header.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix(&format!("{}=", COOKIE_NAME)) { + return Some(value.to_string()); + } + } + } + None +} + +fn token_matches(provided: &str, expected: &str) -> bool { + let a = provided.as_bytes(); + let b = expected.as_bytes(); + if a.len() != b.len() { + // Still do a constant-time compare to make timing uniform on the same-length path. + let _ = a.ct_eq(a); + return false; + } + a.ct_eq(b).into() +} + +pub fn is_authed(headers: &HeaderMap, admin_token: &str) -> bool { + match extract_token(headers) { + Some(t) => token_matches(&t, admin_token), + None => false, + } +} + pub fn check_auth(headers: &HeaderMap, admin_token: &str) -> Result<(), AppError> { - let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); - if auth_header != Some(&format!("Bearer {}", admin_token)) { - warn!("Unauthorized access attempt detected"); + if !is_authed(headers, admin_token) { + warn!("Unauthorized access attempt"); return Err(AppError::Unauthorized); } Ok(()) diff --git a/backend/src/error.rs b/backend/src/error.rs index 97a7d28..f6aaf55 100644 --- a/backend/src/error.rs +++ b/backend/src/error.rs @@ -3,6 +3,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use tracing::error; use crate::models::ErrorResponse; @@ -10,21 +11,28 @@ pub enum AppError { Unauthorized, NotFound(String), BadRequest(String), + /// (public_message, internal_details) — details are logged but not returned. Internal(String, Option), } impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, error_message, details) = match self { - AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string(), None), - AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg, None), - AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg, None), - AppError::Internal(msg, details) => (StatusCode::INTERNAL_SERVER_ERROR, msg, details), + let (status, error_message) = match self { + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized".to_string()), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), + AppError::Internal(msg, details) => { + if let Some(d) = details { + error!("Internal error: {} — {}", msg, d); + } else { + error!("Internal error: {}", msg); + } + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error".to_string()) + } }; let body = Json(ErrorResponse { error: error_message, - details, }); (status, body).into_response() diff --git a/backend/src/handlers/auth.rs b/backend/src/handlers/auth.rs new file mode 100644 index 0000000..edd08f5 --- /dev/null +++ b/backend/src/handlers/auth.rs @@ -0,0 +1,116 @@ +use axum::{ + Json, + extract::State, + http::{HeaderMap, HeaderValue, StatusCode}, + response::{AppendHeaders, IntoResponse}, +}; +use serde::Deserialize; +use std::sync::Arc; +use subtle::ConstantTimeEq; +use tracing::{info, warn}; + +use crate::{AppState, error::AppError}; + +const COOKIE_TOKEN: &str = "admin"; +const COOKIE_FLAG: &str = "admin_session"; +const MAX_AGE_SECS: i64 = 60 * 60 * 24 * 30; // 30 days + +#[derive(Deserialize)] +pub struct LoginRequest { + pub token: String, +} + +fn cookie_attrs(secure: bool) -> String { + let secure_part = if secure { "; Secure" } else { "" }; + format!( + "; HttpOnly{}; SameSite=Strict; Path=/; Max-Age={}", + secure_part, MAX_AGE_SECS + ) +} + +fn flag_cookie_attrs(secure: bool) -> String { + let secure_part = if secure { "; Secure" } else { "" }; + format!( + "{}; SameSite=Strict; Path=/; Max-Age={}", + secure_part, MAX_AGE_SECS + ) +} + +pub async fn login( + State(state): State>, + Json(payload): Json, +) -> Result { + let provided = payload.token.as_bytes(); + let expected = state.admin_token.as_bytes(); + let ok = if provided.len() == expected.len() { + provided.ct_eq(expected).into() + } else { + // Run constant-time compare anyway to flatten timing. + let _: bool = provided.ct_eq(provided).into(); + false + }; + + if !ok { + warn!("Failed login attempt"); + return Err(AppError::Unauthorized); + } + + info!("Admin logged in"); + let token_cookie = format!( + "{}={}{}", + COOKIE_TOKEN, + state.admin_token, + cookie_attrs(state.cookie_secure) + ); + let flag_cookie = format!( + "{}=1{}", + COOKIE_FLAG, + flag_cookie_attrs(state.cookie_secure) + ); + + let headers = AppendHeaders([ + ( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&token_cookie) + .map_err(|e| AppError::Internal("Cookie".to_string(), Some(e.to_string())))?, + ), + ( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&flag_cookie) + .map_err(|e| AppError::Internal("Cookie".to_string(), Some(e.to_string())))?, + ), + ]); + + Ok((StatusCode::NO_CONTENT, headers)) +} + +pub async fn logout(State(state): State>) -> impl IntoResponse { + let secure_part = if state.cookie_secure { "; Secure" } else { "" }; + let token_cookie = format!( + "{}=; HttpOnly{}; SameSite=Strict; Path=/; Max-Age=0", + COOKIE_TOKEN, secure_part + ); + let flag_cookie = format!( + "{}={}; SameSite=Strict; Path=/; Max-Age=0", + COOKIE_FLAG, secure_part + ); + let headers = AppendHeaders([ + ( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&token_cookie).unwrap(), + ), + ( + axum::http::header::SET_COOKIE, + HeaderValue::from_str(&flag_cookie).unwrap(), + ), + ]); + (StatusCode::NO_CONTENT, headers) +} + +pub async fn me(State(state): State>, headers: HeaderMap) -> StatusCode { + if crate::auth::is_authed(&headers, &state.admin_token) { + StatusCode::NO_CONTENT + } else { + StatusCode::UNAUTHORIZED + } +} diff --git a/backend/src/handlers/mod.rs b/backend/src/handlers/mod.rs index 8aefea9..f28e4e9 100644 --- a/backend/src/handlers/mod.rs +++ b/backend/src/handlers/mod.rs @@ -1,3 +1,4 @@ +pub mod auth; pub mod config; pub mod posts; pub mod upload; diff --git a/backend/src/handlers/posts.rs b/backend/src/handlers/posts.rs index dd0359f..2371989 100644 --- a/backend/src/handlers/posts.rs +++ b/backend/src/handlers/posts.rs @@ -2,79 +2,164 @@ use axum::{ Json, extract::{Path, State}, http::{HeaderMap, StatusCode}, - response::IntoResponse, }; +use chrono::Utc; use std::{fs, sync::Arc}; use tracing::{error, info, warn}; use crate::{ AppState, - auth::check_auth, + auth::is_authed, error::AppError, - models::{CreatePostRequest, PostDetail, PostInfo}, + models::{CreatePostRequest, PostDetail, PostInfo, PostMeta}, }; +const WORDS_PER_MINUTE: u32 = 200; + +fn validate_slug(s: &str) -> Result<(), AppError> { + if s.is_empty() + || s.contains('/') + || s.contains('\\') + || s.contains("..") + || s.contains('\0') + { + return Err(AppError::BadRequest("Invalid slug".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 { + 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) +} + +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() +} + +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).map_err(|e| { + AppError::Internal("Write error".to_string(), Some(e.to_string())) + })?; + fs::rename(&tmp_path, &final_path).map_err(|e| { + let _ = fs::remove_file(&tmp_path); + AppError::Internal("Rename error".to_string(), Some(e.to_string())) + })?; + Ok(()) +} + pub async fn create_post( State(state): State>, headers: HeaderMap, Json(payload): Json, ) -> Result, AppError> { - check_auth(&headers, &state.admin_token)?; - - if payload.slug.contains('/') || payload.slug.contains('\\') || payload.slug.contains("..") { - return Err(AppError::BadRequest("Invalid slug".to_string())); + if !is_authed(&headers, &state.admin_token) { + return Err(AppError::Unauthorized); } - let file_path = state - .data_dir - .join("posts") - .join(format!("{}.md", payload.slug)); + validate_slug(&payload.slug)?; + if let Some(ref old) = payload.old_slug { + validate_slug(old)?; + } + + let posts_dir = state.data_dir.join("posts"); + let file_path = posts_dir.join(format!("{}.md", payload.slug)); - // Handle renaming if let Some(ref old_slug) = payload.old_slug { if old_slug != &payload.slug { - let old_file_path = state - .data_dir - .join("posts") - .join(format!("{}.md", old_slug)); - if old_file_path.exists() { - // If new path already exists and it's different from old path, error out + let old_path = posts_dir.join(format!("{}.md", old_slug)); + if old_path.exists() { if file_path.exists() { return Err(AppError::BadRequest( "A post with this new title already exists".to_string(), )); } - if let Err(e) = fs::rename(&old_file_path, &file_path) { + let _guard = state.post_lock.lock().await; + fs::rename(&old_path, &file_path).map_err(|e| { error!("Rename error from {} to {}: {}", old_slug, payload.slug, e); - return Err(AppError::Internal( - "Rename error".to_string(), - Some(e.to_string()), - )); - } + AppError::Internal("Rename error".to_string(), Some(e.to_string())) + })?; + drop(_guard); info!("Renamed post from {} to {}", old_slug, payload.slug); } } } - let mut file_content = String::new(); - if let Some(ref summary) = payload.summary { - if !summary.trim().is_empty() { - file_content.push_str("---\nsummary: "); - file_content.push_str(&summary.replace('\n', " ")); - file_content.push_str("\n---\n"); - } - } - file_content.push_str(&payload.content); + let meta = PostMeta { + date: payload.date.unwrap_or_else(|| Utc::now().date_naive()), + summary: payload.summary.filter(|s| !s.trim().is_empty()), + tags: payload.tags, + draft: payload.draft, + }; + let contents = serialize_post(&meta, &payload.content)?; + write_post_atomic(&state, &payload.slug, &contents).await?; - fs::write(&file_path, &file_content).map_err(|e| { - error!("Write error for post {}: {}", payload.slug, e); - AppError::Internal("Write error".to_string(), Some(e.to_string())) - })?; - - info!("Post created/updated: {}", payload.slug); + info!("Post saved: {}", payload.slug); Ok(Json(PostDetail { slug: payload.slug, - summary: payload.summary, + date: meta.date, + summary: meta.summary, + tags: meta.tags, + draft: meta.draft, + reading_time: reading_time(&payload.content), content: payload.content, })) } @@ -84,10 +169,13 @@ pub async fn delete_post( headers: HeaderMap, Path(slug): Path, ) -> Result { - check_auth(&headers, &state.admin_token)?; + if !is_authed(&headers, &state.admin_token) { + return Err(AppError::Unauthorized); + } + validate_slug(&slug)?; + let _guard = state.post_lock.lock().await; let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); - info!("Attempting to delete post at: {:?}", file_path); if !file_path.exists() { warn!("Post not found for deletion: {}", slug); @@ -103,87 +191,76 @@ pub async fn delete_post( Ok(StatusCode::NO_CONTENT) } -pub async fn list_posts(State(state): State>) -> impl IntoResponse { +pub async fn list_posts( + State(state): State>, + headers: HeaderMap, +) -> Json> { + let admin = is_authed(&headers, &state.admin_token); let posts_dir = state.data_dir.join("posts"); - let mut posts = Vec::new(); + let mut posts: Vec = Vec::new(); if let Ok(entries) = fs::read_dir(posts_dir) { for entry in entries.flatten() { let path = entry.path(); - if path.extension().and_then(|e| e.to_str()) == Some("md") { - if let Some(slug) = path.file_stem().and_then(|s| s.to_str()) { - let mut excerpt = String::new(); - if let Ok(content) = fs::read_to_string(&path) { - if content.starts_with("---\n") { - let parts: Vec<&str> = content.splitn(3, "---\n").collect(); - if parts.len() == 3 { - let frontmatter = parts[1]; - for line in frontmatter.lines() { - if line.starts_with("summary: ") { - excerpt = line.trim_start_matches("summary: ").to_string(); - break; - } - } - if excerpt.is_empty() { - let body = parts[2]; - let clean_content = body.replace("#", "").replace("\n", " "); - excerpt = clean_content.chars().take(200).collect::(); - if clean_content.len() > 200 { - excerpt.push_str("..."); - } - } - } - } else { - let clean_content = content.replace("#", "").replace("\n", " "); - excerpt = clean_content.chars().take(200).collect::(); - if clean_content.len() > 200 { - excerpt.push_str("..."); - } - } - } - posts.push(PostInfo { - slug: slug.to_string(), - excerpt: excerpt.trim().to_string(), - }); - } + 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) else { + continue; + }; + let Ok((meta, body)) = parse_post(&raw) else { + warn!("Skipping post with bad frontmatter: {}", slug); + continue; + }; + if meta.draft && !admin { + continue; + } + posts.push(PostInfo { + slug: slug.to_string(), + date: meta.date, + summary: meta.summary.clone(), + tags: meta.tags.clone(), + draft: meta.draft, + reading_time: reading_time(&body), + excerpt: excerpt_from(&meta, &body), + }); } } + posts.sort_by(|a, b| b.date.cmp(&a.date).then_with(|| a.slug.cmp(&b.slug))); Json(posts) } pub async fn get_post( State(state): State>, + headers: HeaderMap, Path(slug): Path, ) -> Result, AppError> { + validate_slug(&slug)?; + let admin = is_authed(&headers, &state.admin_token); let file_path = state.data_dir.join("posts").join(format!("{}.md", slug)); - match fs::read_to_string(&file_path) { - Ok(raw_content) => { - let mut summary = None; - let mut content = raw_content.clone(); + let raw = fs::read_to_string(&file_path) + .map_err(|_| AppError::NotFound("Post not found".to_string()))?; + let (meta, body) = parse_post(&raw)?; - if raw_content.starts_with("---\n") { - let parts: Vec<&str> = raw_content.splitn(3, "---\n").collect(); - if parts.len() == 3 { - let frontmatter = parts[1]; - for line in frontmatter.lines() { - if line.starts_with("summary: ") { - summary = Some(line.trim_start_matches("summary: ").to_string()); - break; - } - } - content = parts[2].to_string(); - } - } - - Ok(Json(PostDetail { - slug, - summary, - content, - })) - } - Err(_) => Err(AppError::NotFound("Post not found".to_string())), + if meta.draft && !admin { + return Err(AppError::NotFound("Post not found".to_string())); } + + Ok(Json(PostDetail { + slug, + date: meta.date, + summary: meta.summary, + tags: meta.tags, + draft: meta.draft, + reading_time: reading_time(&body), + content: body, + })) } diff --git a/backend/src/handlers/upload.rs b/backend/src/handlers/upload.rs index 583fba3..2e99033 100644 --- a/backend/src/handlers/upload.rs +++ b/backend/src/handlers/upload.rs @@ -19,22 +19,72 @@ pub struct UploadQuery { pub replace: Option, } +/// 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", +]; + +fn validate_filename(name: &str) -> Result<(), AppError> { + if name.is_empty() + || name.contains('/') + || name.contains('\\') + || name.contains("..") + || name.contains('\0') + || name.starts_with('.') + { + return Err(AppError::BadRequest("Invalid filename".to_string())); + } + Ok(()) +} + +fn check_mime_matches_ext(bytes: &[u8], ext: &str) -> bool { + let Some(kind) = infer::get(bytes) else { + // Plain text formats (txt, md) won't be detected by magic bytes. + return matches!(ext, "txt" | "md"); + }; + let mime = kind.mime_type(); + match ext { + "jpg" | "jpeg" => mime == "image/jpeg", + "png" => mime == "image/png", + "webp" => mime == "image/webp", + "gif" => mime == "image/gif", + "avif" => mime == "image/avif", + "pdf" => mime == "application/pdf", + "mp3" => mime == "audio/mpeg", + "wav" => mime == "audio/x-wav" || mime == "audio/wav", + "ogg" => mime == "audio/ogg" || mime == "video/ogg", + "mp4" => mime == "video/mp4", + "webm" => mime == "video/webm", + "mov" => mime == "video/quicktime", + "txt" | "md" => true, + _ => false, + } +} + pub async fn delete_upload( State(state): State>, headers: HeaderMap, Path(filename): Path, ) -> Result { check_auth(&headers, &state.admin_token)?; + validate_filename(&filename)?; - let file_path = state.data_dir.join("uploads").join(&filename); + let uploads_dir = state.data_dir.join("uploads"); + let file_path = uploads_dir.join(&filename); - // Security check to prevent directory traversal - if file_path.parent() != Some(&state.data_dir.join("uploads")) { - return Err(AppError::BadRequest("Invalid filename".to_string())); - } - - if file_path.exists() { - fs::remove_file(file_path).map_err(|e| { + let canonical_dir = uploads_dir.canonicalize().map_err(|e| { + AppError::Internal("Path resolution".to_string(), Some(e.to_string())) + })?; + if let Ok(canonical_file) = file_path.canonicalize() { + if !canonical_file.starts_with(&canonical_dir) { + warn!("Refused delete outside uploads dir: {}", filename); + return Err(AppError::BadRequest("Invalid filename".to_string())); + } + fs::remove_file(canonical_file).map_err(|e| { error!("Delete error for file {}: {}", filename, e); AppError::Internal("Delete error".to_string(), Some(e.to_string())) })?; @@ -82,57 +132,82 @@ pub async fn upload_file( info!("Upload requested"); while let Ok(Some(field)) = multipart.next_field().await { - let file_name = match field.file_name() { + let original_name = match field.file_name() { Some(name) => name.to_string(), None => continue, }; - info!("Processing upload for: {}", file_name); - let slugified_name = slug::slugify(&file_name); + info!("Processing upload for: {}", original_name); - let extension = std::path::Path::new(&file_name) + let extension = std::path::Path::new(&original_name) .extension() .and_then(|e| e.to_str()) - .unwrap_or(""); + .map(|e| e.to_ascii_lowercase()) + .unwrap_or_default(); - let final_name = if !extension.is_empty() { - format!("{}.{}", slugified_name, extension) - } else { - slugified_name - }; + if !ALLOWED_EXTS.contains(&extension.as_str()) { + warn!("Upload rejected: extension '{}' not allowed", extension); + return Err(AppError::BadRequest(format!( + "File type '.{}' not allowed", + extension + ))); + } + + let stem = std::path::Path::new(&original_name) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("file"); + let slugified = slug::slugify(stem); + let final_name = format!("{}.{}", slugified, extension); + + let data = field.bytes().await.map_err(|e| { + error!("Failed to read multipart bytes: {}", e); + AppError::BadRequest(format!("Read error: {}", e)) + })?; + + if !check_mime_matches_ext(&data, &extension) { + warn!( + "Upload rejected: magic bytes don't match extension '{}'", + extension + ); + return Err(AppError::BadRequest( + "File contents don't match extension".to_string(), + )); + } let uploads_dir = state.data_dir.join("uploads"); - let file_path = uploads_dir.join(&final_name); + let target_path = uploads_dir.join(&final_name); - let final_path = if file_path.exists() && !query.replace.unwrap_or(false) { + let final_path = if target_path.exists() && !query.replace.unwrap_or(false) { let timestamp = chrono::Utc::now().timestamp(); uploads_dir.join(format!("{}_{}", timestamp, final_name)) } else { - file_path + target_path }; + // Final containment check. + let canonical_dir = uploads_dir.canonicalize().map_err(|e| { + AppError::Internal("Path resolution".to_string(), Some(e.to_string())) + })?; + if let Some(parent) = final_path.parent() { + let canonical_parent = parent.canonicalize().map_err(|e| { + AppError::Internal("Path resolution".to_string(), Some(e.to_string())) + })?; + if canonical_parent != canonical_dir { + return Err(AppError::BadRequest("Invalid filename".to_string())); + } + } + let final_name_str = final_path .file_name() - .unwrap() - .to_str() - .unwrap() + .and_then(|n| n.to_str()) + .unwrap_or(&final_name) .to_string(); - let data = match field.bytes().await { - Ok(bytes) => bytes, - Err(e) => { - error!("Failed to read multipart bytes: {}", e); - return Err(AppError::BadRequest(format!("Read error: {}", e))); - } - }; - - if let Err(e) = fs::write(&final_path, &data) { + fs::write(&final_path, &data).map_err(|e| { error!("Failed to write file to {:?}: {}", final_path, e); - return Err(AppError::Internal( - "Write error".to_string(), - Some(e.to_string()), - )); - } + AppError::Internal("Write error".to_string(), Some(e.to_string())) + })?; info!("File uploaded successfully to {:?}", final_path); return Ok(Json(UploadResponse { diff --git a/backend/src/main.rs b/backend/src/main.rs index c3e7ed9..8db5634 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -6,19 +6,22 @@ pub mod models; use axum::{ Router, extract::DefaultBodyLimit, + http::{HeaderValue, header}, routing::{delete, get, post}, }; use std::{env, fs, path::PathBuf, sync::Arc}; +use tokio::sync::Mutex; use tower_http::{ - cors::{Any, CorsLayer}, + cors::{AllowOrigin, CorsLayer}, services::ServeDir, }; -use tracing::{error, info}; +use tracing::{error, info, warn}; -#[derive(Clone)] pub struct AppState { pub admin_token: String, pub data_dir: PathBuf, + pub cookie_secure: bool, + pub post_lock: Mutex<()>, } #[tokio::main] @@ -27,13 +30,23 @@ async fn main() { dotenvy::dotenv().ok(); let port = env::var("PORT").unwrap_or_else(|_| "3000".to_string()); - let admin_token = env::var("ADMIN_TOKEN").unwrap_or_else(|_| "secret".to_string()); + let admin_token = env::var("ADMIN_TOKEN") + .ok() + .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." + ); + } let data_dir_str = env::var("DATA_DIR").unwrap_or_else(|_| "../data".to_string()); let data_dir = PathBuf::from(data_dir_str); + let cookie_secure = env::var("COOKIE_SECURE") + .map(|v| v != "false" && v != "0") + .unwrap_or(true); info!("Initializing backend with data dir: {:?}", data_dir); - // Ensure directories exist let posts_dir = data_dir.join("posts"); let uploads_dir = data_dir.join("uploads"); if let Err(e) = fs::create_dir_all(&posts_dir) { @@ -46,14 +59,36 @@ async fn main() { let state = Arc::new(AppState { admin_token, data_dir, + cookie_secure, + post_lock: Mutex::new(()), }); - let cors = CorsLayer::new() - .allow_origin(Any) - .allow_methods(Any) - .allow_headers(Any); + // CORS — locked down by default. Set FRONTEND_ORIGIN to the public URL of + // the frontend if you ever expose the backend directly to browsers. + // Normal deployments hit the backend through the Astro proxy, which is + // 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"); + CorsLayer::new() + .allow_origin(AllowOrigin::exact(value)) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::DELETE, + axum::http::Method::OPTIONS, + ]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) + .allow_credentials(true) + } + None => CorsLayer::new(), + }; let app = Router::new() + .route("/api/auth/login", post(handlers::auth::login)) + .route("/api/auth/logout", post(handlers::auth::logout)) + .route("/api/auth/me", get(handlers::auth::me)) .route( "/api/config", get(handlers::config::get_config).post(handlers::config::update_config), @@ -72,6 +107,7 @@ async fn main() { delete(handlers::upload::delete_upload), ) .route("/api/upload", post(handlers::upload::upload_file)) + .route("/healthz", get(|| async { "ok" })) .nest_service("/uploads", ServeDir::new(uploads_dir)) .layer(DefaultBodyLimit::max(50 * 1024 * 1024)) .layer(cors) diff --git a/backend/src/models.rs b/backend/src/models.rs index e1ee832..dde8744 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -1,3 +1,4 @@ +use chrono::NaiveDate; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize, Clone)] @@ -28,32 +29,60 @@ impl Default for SiteConfig { } } +#[derive(Serialize, Deserialize, Clone, Default)] +pub struct PostMeta { + pub date: NaiveDate, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + #[serde(default)] + pub draft: bool, +} + #[derive(Serialize)] pub struct PostInfo { pub slug: String, + pub date: NaiveDate, + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + pub tags: Vec, + pub draft: bool, + pub reading_time: u32, pub excerpt: String, } #[derive(Serialize)] pub struct PostDetail { pub slug: String, + pub date: NaiveDate, + #[serde(skip_serializing_if = "Option::is_none")] pub summary: Option, + pub tags: Vec, + pub draft: bool, + pub reading_time: u32, pub content: String, } #[derive(Deserialize)] pub struct CreatePostRequest { pub slug: String, + #[serde(default)] pub old_slug: Option, + #[serde(default)] + pub date: Option, + #[serde(default)] pub summary: Option, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub draft: bool, pub content: String, } #[derive(Serialize)] pub struct ErrorResponse { pub error: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub details: Option, } #[derive(Serialize)] diff --git a/data/posts/another-post.md b/data/posts/another-post.md index 3bb8dbf..7ef0f07 100644 --- a/data/posts/another-post.md +++ b/data/posts/another-post.md @@ -1,3 +1,10 @@ +--- +date: 2026-05-09 +summary: Markdown smoke test — bold, italic, links, blockquotes, fenced code. +tags: + - meta +draft: false +--- # My Second Blog Post Adding some more content to test the layout and the glassy look! diff --git a/data/posts/hello-world.md b/data/posts/hello-world.md index 4c1ba20..0b04412 100644 --- a/data/posts/hello-world.md +++ b/data/posts/hello-world.md @@ -1,3 +1,11 @@ +--- +date: 2026-05-09 +summary: First post — modern stack, glassy aesthetic, Catppuccin theme. +tags: + - meta + - intro +draft: false +--- # Welcome to Narlblog This is my very first blog post! Built with a modern, glassy aesthetic and the beautiful Catppuccin color palette. diff --git a/docker-compose.yml b/docker-compose.yml index 8db07ea..50a1a38 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,34 @@ services: backend: - build: + build: context: ./backend dockerfile: Dockerfile - ports: - - "3000:3000" + expose: + - "3000" volumes: - ./data:/app/data environment: - PORT=3000 - - ADMIN_TOKEN=${ADMIN_TOKEN:-default_insecure_token} + # No fallback — fail fast if ADMIN_TOKEN isn't set in your .env. + - ADMIN_TOKEN=${ADMIN_TOKEN:?ADMIN_TOKEN must be set in .env} - DATA_DIR=/app/data + - COOKIE_SECURE=${COOKIE_SECURE:-true} + - FRONTEND_ORIGIN=${FRONTEND_ORIGIN:-} + - RUST_LOG=${RUST_LOG:-info} restart: unless-stopped networks: - internal_net + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:3000/healthz"] + interval: 15s + timeout: 3s + retries: 5 + start_period: 10s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" frontend: build: @@ -24,10 +39,16 @@ services: environment: - PUBLIC_API_URL=http://backend:3000 depends_on: - - backend + backend: + condition: service_healthy restart: unless-stopped networks: - internal_net + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" networks: internal_net: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..1c4d31b --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.astro/ +.git/ +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile index c899d35..3bdc23c 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,19 +1,21 @@ -FROM node:latest AS builder +FROM node:22.12-alpine AS builder WORKDIR /app -COPY package*.json ./ -RUN npm install +COPY package.json package-lock.json ./ +RUN npm ci COPY . . RUN npm run build -FROM node:latest +FROM node:22.12-alpine WORKDIR /app COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json +USER node + ENV HOST=0.0.0.0 ENV PORT=4321 EXPOSE 4321 diff --git a/frontend/astro.config.mjs b/frontend/astro.config.mjs index 1cc9bde..f6fe766 100644 --- a/frontend/astro.config.mjs +++ b/frontend/astro.config.mjs @@ -9,9 +9,6 @@ import node from '@astrojs/node'; export default defineConfig({ output: 'server', integrations: [react()], - security: { - checkOrigin: false - }, image: { service: { entrypoint: 'astro/assets/services/noop' } }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c818a45..397fac3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -17,28 +17,135 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@fontsource-variable/inter": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.2.5", "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "codemirror": "^6.0.2", "highlight.js": "^11.11.1", + "isomorphic-dompurify": "^2.18.0", "katex": "^0.16.44", "marked": "^17.0.5", + "marked-gfm-heading-id": "^4.1.2", + "marked-highlight": "^2.2.2", + "marked-katex-extension": "^5.1.5", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwindcss": "^4.2.2", "zustand": "^5.0.12" }, "devDependencies": { + "@astrojs/check": "^0.9.9", "@types/katex": "^0.16.8", "@types/node": "^25.5.0", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3" + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3" }, "engines": { "node": ">=22.12.0" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "license": "MIT" + }, + "node_modules/@astrojs/check": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.9.tgz", + "integrity": "sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/language-server": "^2.16.7", + "chokidar": "^4.0.3", + "kleur": "^4.1.5", + "yargs": "^17.7.2" + }, + "bin": { + "astro-check": "bin/astro-check.js" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/@astrojs/check/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@astrojs/check/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@astrojs/compiler": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-3.0.1.tgz", @@ -54,6 +161,55 @@ "picomatch": "^4.0.3" } }, + "node_modules/@astrojs/language-server": { + "version": "2.16.8", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.16.8.tgz", + "integrity": "sha512-yg1pZF6hs9FaKr2fgXMOGbW7pDLgFexFjuhWilPAc8VybTU+WSnbfbhYaUL1exm6dAK4sM3aKXGcfVwss+HXbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@astrojs/compiler": "^2.13.1", + "@astrojs/yaml2ts": "^0.2.3", + "@jridgewell/sourcemap-codec": "^1.5.5", + "@volar/kit": "~2.4.28", + "@volar/language-core": "~2.4.28", + "@volar/language-server": "~2.4.28", + "@volar/language-service": "~2.4.28", + "muggle-string": "^0.4.1", + "tinyglobby": "^0.2.16", + "volar-service-css": "0.0.70", + "volar-service-emmet": "0.0.70", + "volar-service-html": "0.0.70", + "volar-service-prettier": "0.0.70", + "volar-service-typescript": "0.0.70", + "volar-service-typescript-twoslash-queries": "0.0.70", + "volar-service-yaml": "0.0.70", + "vscode-html-languageservice": "^5.6.2", + "vscode-uri": "^3.1.0" + }, + "bin": { + "astro-ls": "bin/nodeServer.js" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "prettier-plugin-astro": ">=0.11.0" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + } + } + }, + "node_modules/@astrojs/language-server/node_modules/@astrojs/compiler": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.13.1.tgz", + "integrity": "sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@astrojs/markdown-remark": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/@astrojs/markdown-remark/-/markdown-remark-7.0.1.tgz", @@ -148,6 +304,16 @@ "node": "18.20.8 || ^20.3.0 || >=22.0.0" } }, + "node_modules/@astrojs/yaml2ts": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@astrojs/yaml2ts/-/yaml2ts-0.2.3.tgz", + "integrity": "sha512-PJzRmgQzUxI2uwpdX2lXSHtP4G8ocp24/t+bZyf5Fy0SZLSF9f9KXZoMlFM/XCGue+B0nH/2IZ7FpBYQATBsCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^2.8.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -438,6 +604,18 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@capsizecss/unpack": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@capsizecss/unpack/-/unpack-4.0.0.tgz", @@ -867,6 +1045,202 @@ "w3c-keyname": "^2.2.4" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emmetio/abbreviation": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz", + "integrity": "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@emmetio/css-abbreviation": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@emmetio/css-abbreviation/-/css-abbreviation-2.1.8.tgz", + "integrity": "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/scanner": "^1.0.4" + } + }, + "node_modules/@emmetio/css-parser": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@emmetio/css-parser/-/css-parser-0.4.1.tgz", + "integrity": "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/stream-reader": "^2.2.0", + "@emmetio/stream-reader-utils": "^0.1.0" + } + }, + "node_modules/@emmetio/html-matcher": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@emmetio/html-matcher/-/html-matcher-1.3.0.tgz", + "integrity": "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@emmetio/scanner": "^1.0.0" + } + }, + "node_modules/@emmetio/scanner": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@emmetio/scanner/-/scanner-1.0.4.tgz", + "integrity": "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emmetio/stream-reader": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader/-/stream-reader-2.2.0.tgz", + "integrity": "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@emmetio/stream-reader-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@emmetio/stream-reader-utils/-/stream-reader-utils-0.1.0.tgz", + "integrity": "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==", + "dev": true, + "license": "MIT" + }, "node_modules/@emnapi/runtime": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", @@ -1293,6 +1667,41 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@img/colour": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", @@ -2939,6 +3348,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2971,6 +3387,171 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/@volar/kit": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/kit/-/kit-2.4.28.tgz", + "integrity": "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", + "typesafe-path": "^0.2.2", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/language-server": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-server/-/language-server-2.4.28.tgz", + "integrity": "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@volar/language-service": "2.4.28", + "@volar/typescript": "2.4.28", + "path-browserify": "^1.0.1", + "request-light": "^0.7.0", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/language-service": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-service/-/language-service-2.4.28.tgz", + "integrity": "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "vscode-languageserver-protocol": "^3.17.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vscode/emmet-helper": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz", + "integrity": "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "emmet": "^2.4.3", + "jsonc-parser": "^2.3.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.15.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -3130,6 +3711,15 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -3259,6 +3849,21 @@ "node": ">=8" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3283,6 +3888,26 @@ "@codemirror/view": "^6.0.0" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/comma-separated-tokens": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", @@ -3425,12 +4050,40 @@ "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", "license": "CC0-1.0" }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -3448,6 +4101,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -3587,6 +4246,15 @@ "url": "https://github.com/fb55/domhandler?sponsor=1" } }, + "node_modules/dompurify": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/domutils": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", @@ -3622,6 +4290,30 @@ "integrity": "sha512-QNQ5l45DzYytThO21403XN3FvK0hOkWDG8viNf6jqS42msJ8I4tGDSpBCgvDRRPnkffafiwAym2X2eHeGD2V0w==", "license": "ISC" }, + "node_modules/emmet": { + "version": "2.4.11", + "resolved": "https://registry.npmjs.org/emmet/-/emmet-2.4.11.tgz", + "integrity": "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "./packages/scanner", + "./packages/abbreviation", + "./packages/css-abbreviation", + "./" + ], + "dependencies": { + "@emmetio/abbreviation": "^2.3.3", + "@emmetio/css-abbreviation": "^2.1.8" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -3757,6 +4449,30 @@ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3836,6 +4552,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/github-slugger": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz", @@ -4051,6 +4777,18 @@ "node": ">=12.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz", @@ -4093,6 +4831,32 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4123,6 +4887,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -4153,6 +4927,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -4168,6 +4948,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isomorphic-dompurify": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz", + "integrity": "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.1", + "jsdom": "^28.0.0" + }, + "engines": { + "node": ">=20.19.5" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -4195,6 +4988,70 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4207,6 +5064,13 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -4219,6 +5083,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.3.1.tgz", + "integrity": "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==", + "dev": true, + "license": "MIT" + }, "node_modules/katex": { "version": "0.16.44", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", @@ -4244,6 +5115,16 @@ "node": ">= 12" } }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -4566,6 +5447,37 @@ "node": ">= 20" } }, + "node_modules/marked-gfm-heading-id": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/marked-gfm-heading-id/-/marked-gfm-heading-id-4.1.4.tgz", + "integrity": "sha512-CspnvVfHSkb/znqdPS4jUR8HtCjq3M/DnrsJCrfLBLvdrgbemmoINKpeWKQYkBiXAoBGejw0cV7xzqrPdup3WA==", + "license": "MIT", + "dependencies": { + "github-slugger": "^2.0.0" + }, + "peerDependencies": { + "marked": ">=13 <19" + } + }, + "node_modules/marked-highlight": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/marked-highlight/-/marked-highlight-2.2.4.tgz", + "integrity": "sha512-PZxisNMJDduSjc0q6uvjsnqqHCXc9s0eyzxDO9sB1eNGJnd/H1/Fu+z6g/liC1dfJdFW4SftMwMlLvsBhUPrqQ==", + "license": "MIT", + "peerDependencies": { + "marked": ">=4 <19" + } + }, + "node_modules/marked-katex-extension": { + "version": "5.1.8", + "resolved": "https://registry.npmjs.org/marked-katex-extension/-/marked-katex-extension-5.1.8.tgz", + "integrity": "sha512-TsV9OCHHDjVBf4IH0RSjLs4Eqsjj8HGfmVCKlimrS391EtBBxzXj2gBYdF9tY7f7oXu9tb1kHV86ExJsG3iMhw==", + "license": "MIT", + "peerDependencies": { + "katex": ">=0.16 <0.17", + "marked": ">=4 <19" + } + }, "node_modules/mdast-util-definitions": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-6.0.0.tgz", @@ -5400,6 +6312,13 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -5614,6 +6533,13 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, "node_modules/piccolore": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/piccolore/-/piccolore-0.1.3.tgz", @@ -5666,6 +6592,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/prismjs": { "version": "1.30.0", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", @@ -5685,6 +6627,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/radix3": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/radix3/-/radix3-1.1.2.tgz", @@ -5909,6 +6860,32 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/request-light": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/retext": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/retext/-/retext-9.0.0.tgz", @@ -6023,6 +7000,18 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6189,6 +7178,21 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -6203,6 +7207,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", @@ -6234,6 +7251,12 @@ "url": "https://opencollective.com/svgo" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT" + }, "node_modules/tailwindcss": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", @@ -6278,13 +7301,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -6293,6 +7316,24 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", + "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.30" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.30", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", + "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", + "license": "MIT" + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -6302,6 +7343,30 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -6349,6 +7414,37 @@ "license": "0BSD", "optional": true }, + "node_modules/typesafe-path": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/typesafe-path/-/typesafe-path-0.2.2.tgz", + "integrity": "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-auto-import-cache": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/typescript-auto-import-cache/-/typescript-auto-import-cache-0.3.6.tgz", + "integrity": "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.3.8" + } + }, "node_modules/ufo": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", @@ -6367,6 +7463,15 @@ "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -6788,12 +7893,279 @@ } } }, + "node_modules/volar-service-css": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-css/-/volar-service-css-0.0.70.tgz", + "integrity": "sha512-K1qyOvBpE3rzdAv3e4/6Rv5yizrYPy5R/ne3IWCAzLBuMO4qBMV3kSqWzj6KUVe6S0AnN6wxF7cRkiaKfYMYJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-css-languageservice": "^6.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-emmet": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-emmet/-/volar-service-emmet-0.0.70.tgz", + "integrity": "sha512-xi5bC4m/VyE3zy/n2CXspKeDZs3qA41tHLTw275/7dNWM/RqE2z3BnDICQybHIVp/6G1iOQj5c1qXMgQC08TNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@emmetio/css-parser": "^0.4.1", + "@emmetio/html-matcher": "^1.3.0", + "@vscode/emmet-helper": "^2.9.3", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-html": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-html/-/volar-service-html-0.0.70.tgz", + "integrity": "sha512-eR6vCgMdmYAo4n+gcT7DSyBQbwB8S3HZZvSagTf0sxNaD4WppMCFfpqWnkrlGStPKMZvMiejRRVmqsX9dYcTvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-html-languageservice": "^5.3.0", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-prettier": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-prettier/-/volar-service-prettier-0.0.70.tgz", + "integrity": "sha512-Z6BCFSpGVCd8BPAsZ785Kce1BGlWd5ODqmqZGVuB14MJvrR4+CYz6cDy4F+igmE1gMifqfvMhdgT8Aud4M5ngg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0", + "prettier": "^2.2 || ^3.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + }, + "prettier": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-typescript/-/volar-service-typescript-0.0.70.tgz", + "integrity": "sha512-l46Bx4cokkUedTd74ojO5H/zqHZJ8SUuyZ0IB8JN4jfRqUM3bQFBHoOwlZCyZmOeO0A3RQNkMnFclxO4c++gsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-browserify": "^1.0.1", + "semver": "^7.6.2", + "typescript-auto-import-cache": "^0.3.5", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-nls": "^5.2.0", + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-typescript-twoslash-queries": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-typescript-twoslash-queries/-/volar-service-typescript-twoslash-queries-0.0.70.tgz", + "integrity": "sha512-IdD13Z9N2Bu8EM6CM0fDV1E69olEYGHDU25X51YXmq8Y0CmJ2LNj6gOiBJgpS5JGUqFzECVhMNBW7R0sPdRTMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/volar-service-yaml": { + "version": "0.0.70", + "resolved": "https://registry.npmjs.org/volar-service-yaml/-/volar-service-yaml-0.0.70.tgz", + "integrity": "sha512-0c8bXDBeoATF9F6iPIlOuYTuZAC4c+yi0siQo920u7eiBJk8oQmUmg9cDUbR4+Gl++bvGP4plj3fErbJuPqdcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-uri": "^3.0.8", + "yaml-language-server": "~1.20.0" + }, + "peerDependencies": { + "@volar/language-service": "~2.4.0" + }, + "peerDependenciesMeta": { + "@volar/language-service": { + "optional": true + } + } + }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-html-languageservice": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-4.1.8.tgz", + "integrity": "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsonc-parser": "^3.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.16.0", + "vscode-nls": "^5.0.0", + "vscode-uri": "^3.0.2" + }, + "engines": { + "npm": ">=7.0.0" + } + }, + "node_modules/vscode-json-languageservice/node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-9.0.1.tgz", + "integrity": "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.5" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, "node_modules/w3c-keyname": { "version": "2.2.8", "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -6804,6 +8176,38 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/which-pm-runs": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/which-pm-runs/-/which-pm-runs-1.1.0.tgz", @@ -6813,18 +8217,139 @@ "node": ">=4" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", "integrity": "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==", "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC" }, + "node_modules/yaml": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.4.tgz", + "integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==", + "devOptional": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yaml-language-server": { + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/yaml-language-server/-/yaml-language-server-1.20.0.tgz", + "integrity": "sha512-qhjK/bzSRZ6HtTvgeFvjNPJGWdZ0+x5NREV/9XZWFjIGezew2b4r5JPy66IfOhd5OA7KeFwk1JfmEbnTvev0cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "ajv": "^8.17.1", + "ajv-draft-04": "^1.0.0", + "prettier": "^3.5.0", + "request-light": "^0.5.7", + "vscode-json-languageservice": "4.1.8", + "vscode-languageserver": "^9.0.0", + "vscode-languageserver-textdocument": "^1.0.1", + "vscode-languageserver-types": "^3.16.0", + "vscode-uri": "^3.0.2", + "yaml": "2.7.1" + }, + "bin": { + "yaml-language-server": "bin/yaml-language-server" + } + }, + "node_modules/yaml-language-server/node_modules/request-light": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.5.8.tgz", + "integrity": "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yaml-language-server/node_modules/yaml": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.1.tgz", + "integrity": "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/yargs-parser": { "version": "22.0.0", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", @@ -6834,6 +8359,16 @@ "node": "^20.19.0 || ^22.12.0 || >=23" } }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index c6a429d..1cbdc57 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,22 +21,30 @@ "@codemirror/search": "^6.6.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.40.0", + "@fontsource-variable/inter": "^5.2.5", + "@fontsource-variable/jetbrains-mono": "^5.2.5", "@replit/codemirror-vim": "^6.3.0", "@tailwindcss/vite": "^4.2.2", "astro": "^6.0.8", "codemirror": "^6.0.2", "highlight.js": "^11.11.1", + "isomorphic-dompurify": "^2.18.0", "katex": "^0.16.44", "marked": "^17.0.5", + "marked-gfm-heading-id": "^4.1.2", + "marked-highlight": "^2.2.2", + "marked-katex-extension": "^5.1.5", "react": "^19.2.4", "react-dom": "^19.2.4", "tailwindcss": "^4.2.2", "zustand": "^5.0.12" }, "devDependencies": { + "@astrojs/check": "^0.9.9", "@types/katex": "^0.16.8", "@types/node": "^25.5.0", "@types/react": "^19.2.14", - "@types/react-dom": "^19.2.3" + "@types/react-dom": "^19.2.3", + "typescript": "^5.9.3" } } diff --git a/frontend/src/components/PostCard.astro b/frontend/src/components/PostCard.astro index 42d886b..a91d3d5 100644 --- a/frontend/src/components/PostCard.astro +++ b/frontend/src/components/PostCard.astro @@ -1,25 +1,53 @@ --- interface Props { slug: string; + date: string; excerpt?: string; + tags?: string[]; + draft?: boolean; + readingTime: number; formatSlug: (slug: string) => string; } -const { slug, excerpt, formatSlug } = Astro.props; +const { slug, date, excerpt, tags = [], draft = false, readingTime, formatSlug } = Astro.props; + +const formattedDate = new Date(date).toLocaleDateString('en-US', { + year: 'numeric', month: 'short', day: 'numeric' +}); --- -
-
-

+
+
+
+ + · + {readingTime} min read + {draft && ( + <> + · + Draft + + )} +
+

{formatSlug(slug)}

-

+

{excerpt || `Read more about ${formatSlug(slug)}...`}

+ {tags.length > 0 && ( +
+ {tags.map(tag => ( + + {tag} + + ))} +
+ )}
diff --git a/frontend/src/components/react/PostEnhancer.tsx b/frontend/src/components/react/PostEnhancer.tsx deleted file mode 100644 index c0a1342..0000000 --- a/frontend/src/components/react/PostEnhancer.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect } from 'react'; -import hljs from 'highlight.js'; -import katex from 'katex'; -import 'katex/dist/katex.min.css'; - -function renderMath(element: HTMLElement) { - const delimiters = [ - { left: '$$', right: '$$', display: true }, - { left: '$', right: '$', display: false }, - { left: '\\(', right: '\\)', display: false }, - { left: '\\[', right: '\\]', display: true }, - ]; - - const walk = (node: Node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement; - if (el.tagName === 'CODE' || el.tagName === 'PRE') return; - for (const child of Array.from(el.childNodes)) walk(child); - } else if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent || ''; - for (const { left, right, display } of delimiters) { - const idx = text.indexOf(left); - if (idx === -1) continue; - const end = text.indexOf(right, idx + left.length); - if (end === -1) continue; - const tex = text.slice(idx + left.length, end); - try { - const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false }); - const span = document.createElement('span'); - span.innerHTML = rendered; - const range = document.createRange(); - range.setStart(node, idx); - range.setEnd(node, end + right.length); - range.deleteContents(); - range.insertNode(span); - } catch { /* skip invalid tex */ } - return; - } - } - }; - - for (let i = 0; i < 3; i++) walk(element); -} - -interface Props { - containerId: string; -} - -export default function PostEnhancer({ containerId }: Props) { - useEffect(() => { - const el = document.getElementById(containerId); - if (!el) return; - - renderMath(el); - el.querySelectorAll('pre code').forEach((block) => { - hljs.highlightElement(block); - }); - }, [containerId]); - - return null; -} diff --git a/frontend/src/components/react/ThemeSwitcher.tsx b/frontend/src/components/react/ThemeSwitcher.tsx index f4c7fd0..5b39de2 100644 --- a/frontend/src/components/react/ThemeSwitcher.tsx +++ b/frontend/src/components/react/ThemeSwitcher.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; const THEMES = [ { value: 'mocha', label: 'Mocha' }, @@ -19,10 +19,23 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) { } return defaultTheme; }); + const [toast, setToast] = useState(null); + const isFirst = useRef(true); useEffect(() => { - document.documentElement.className = theme; + const html = document.documentElement; + THEMES.forEach(t => html.classList.remove(t.value)); + html.classList.add(theme); localStorage.setItem('user-theme', theme); + + if (isFirst.current) { + isFirst.current = false; + return; + } + const label = THEMES.find(t => t.value === theme)?.label ?? theme; + setToast(`Theme: ${label}`); + const id = setTimeout(() => setToast(null), 1200); + return () => clearTimeout(id); }, [theme]); return ( @@ -30,15 +43,17 @@ export default function ThemeSwitcher({ defaultTheme = 'mocha' }: Props) {
- +
+ {toast &&
{toast}
}

); } diff --git a/frontend/src/components/react/admin/AssetManager.tsx b/frontend/src/components/react/admin/AssetManager.tsx index 3d622e7..0816526 100644 --- a/frontend/src/components/react/admin/AssetManager.tsx +++ b/frontend/src/components/react/admin/AssetManager.tsx @@ -85,7 +85,7 @@ export default function AssetManager({ mode = 'manage', onSelect }: Props) { ) : (
{assets.map(asset => ( -
+
{isImage(asset.name) ? ( {asset.name} diff --git a/frontend/src/components/react/admin/Dashboard.tsx b/frontend/src/components/react/admin/Dashboard.tsx index 1816edc..5ac1f19 100644 --- a/frontend/src/components/react/admin/Dashboard.tsx +++ b/frontend/src/components/react/admin/Dashboard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, type ReactElement } from 'react'; import { useAuth } from '../../../stores/auth'; import { getPosts, deletePost, ApiError } from '../../../lib/api'; import type { Post } from '../../../lib/types'; @@ -35,7 +35,6 @@ export default function Dashboard() { function handleLogout() { logout(); - window.location.href = '/'; } return ( @@ -94,7 +93,7 @@ export default function Dashboard() { ); } -const ICONS: Record = { +const ICONS: Record = { write: , assets: , settings: , diff --git a/frontend/src/components/react/admin/Editor.tsx b/frontend/src/components/react/admin/Editor.tsx index 7febc22..affb04c 100644 --- a/frontend/src/components/react/admin/Editor.tsx +++ b/frontend/src/components/react/admin/Editor.tsx @@ -7,9 +7,7 @@ import { defaultKeymap, indentWithTab } from '@codemirror/commands'; import { vim } from '@replit/codemirror-vim'; import { search, searchKeymap } from '@codemirror/search'; import { closeBrackets } from '@codemirror/autocomplete'; -import { marked } from 'marked'; -import hljs from 'highlight.js'; -import katex from 'katex'; +import { renderMarkdown } from '../../../lib/markdown'; import { getPost, savePost, deletePost, getAssets, ApiError } from '../../../lib/api'; import type { Asset } from '../../../lib/types'; import AssetManager from './AssetManager'; @@ -58,43 +56,6 @@ const narlblogTheme = EditorView.theme({ }, }, { dark: true }); -function renderMathInElement(element: HTMLElement) { - const delimiters = [ - { left: '$$', right: '$$', display: true }, - { left: '$', right: '$', display: false }, - { left: '\\(', right: '\\)', display: false }, - { left: '\\[', right: '\\]', display: true }, - ]; - const walk = (node: Node) => { - if (node.nodeType === Node.ELEMENT_NODE) { - const el = node as HTMLElement; - if (el.tagName === 'CODE' || el.tagName === 'PRE') return; - for (const child of Array.from(el.childNodes)) walk(child); - } else if (node.nodeType === Node.TEXT_NODE) { - const text = node.textContent || ''; - for (const { left, right, display } of delimiters) { - const idx = text.indexOf(left); - if (idx === -1) continue; - const end = text.indexOf(right, idx + left.length); - if (end === -1) continue; - const tex = text.slice(idx + left.length, end); - try { - const rendered = katex.renderToString(tex, { displayMode: display, throwOnError: false }); - const span = document.createElement('span'); - span.innerHTML = rendered; - const range = document.createRange(); - range.setStart(node, idx); - range.setEnd(node, end + right.length); - range.deleteContents(); - range.insertNode(span); - } catch { /* skip */ } - return; - } - } - }; - for (let i = 0; i < 3; i++) walk(element); -} - // Compartment for hot-swapping vim mode without recreating the editor const vimCompartment = new Compartment(); @@ -104,8 +65,12 @@ export default function Editor({ editSlug }: Props) { const previewRef = useRef(null); const previewTimerRef = useRef | null>(null); const updatePreviewRef = useRef<() => void>(() => {}); + const today = new Date().toISOString().slice(0, 10); const [slug, setSlug] = useState(editSlug || ''); + const [date, setDate] = useState(today); const [summary, setSummary] = useState(''); + const [tagsInput, setTagsInput] = useState(''); + const [draft, setDraft] = useState(false); const [originalSlug, setOriginalSlug] = useState(editSlug || ''); const [alert, setAlert] = useState<{ msg: string; type: 'success' | 'error' } | null>(null); const [showModal, setShowModal] = useState(false); @@ -126,19 +91,7 @@ export default function Editor({ editSlug }: Props) { const updatePreview = useCallback(() => { if (!showPreview || !viewRef.current || !previewRef.current) return; const content = viewRef.current.state.doc.toString(); - const result = marked.parse(content); - if (typeof result === 'string') { - previewRef.current.innerHTML = result; - } else { - result.then(h => { if (previewRef.current) previewRef.current.innerHTML = h; }); - } - requestAnimationFrame(() => { - if (!previewRef.current) return; - renderMathInElement(previewRef.current); - previewRef.current.querySelectorAll('pre code').forEach(block => { - hljs.highlightElement(block); - }); - }); + previewRef.current.innerHTML = renderMarkdown(content); }, [showPreview]); useEffect(() => { updatePreviewRef.current = updatePreview; }, [updatePreview]); @@ -194,6 +147,9 @@ export default function Editor({ editSlug }: Props) { if (!editSlug) return; getPost(editSlug).then(post => { if (post.summary) setSummary(post.summary); + if (post.date) setDate(post.date); + if (post.tags?.length) setTagsInput(post.tags.join(', ')); + setDraft(!!post.draft); if (post.content && viewRef.current) { viewRef.current.dispatch({ changes: { from: 0, to: viewRef.current.state.doc.length, insert: post.content }, @@ -257,11 +213,18 @@ export default function Editor({ editSlug }: Props) { showAlertMsg('Title and content are required.', 'error'); return; } + const tags = tagsInput + .split(',') + .map(t => t.trim()) + .filter(Boolean); try { await savePost({ slug, old_slug: originalSlug || null, + date, summary: summary || null, + tags, + draft, content, }); showAlertMsg('Post saved!', 'success'); @@ -323,17 +286,51 @@ export default function Editor({ editSlug }: Props) {
- {/* Slug */} -
- - setSlug(e.target.value)} - required - placeholder="my-awesome-post" - className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono" - /> + {/* Slug + Date */} +
+
+ + setSlug(e.target.value)} + required + placeholder="my-awesome-post" + className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono" + /> +
+
+ + setDate(e.target.value)} + className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors font-mono" + /> +
+
+ + {/* Tags + Draft */} +
+
+ + setTagsInput(e.target.value)} + placeholder="rust, astro, design" + className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors" + /> +
+
{/* Summary */} diff --git a/frontend/src/components/react/admin/Login.tsx b/frontend/src/components/react/admin/Login.tsx index c0b3421..baa0e45 100644 --- a/frontend/src/components/react/admin/Login.tsx +++ b/frontend/src/components/react/admin/Login.tsx @@ -1,15 +1,30 @@ import { useState } from 'react'; +import { login, ApiError } from '../../../lib/api'; import { useAuth } from '../../../stores/auth'; export default function Login() { const [value, setValue] = useState(''); - const setToken = useAuth(s => s.setToken); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const setLoggedIn = useAuth(s => s.setLoggedIn); - function handleSubmit(e: React.FormEvent) { + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); - if (value.trim()) { - setToken(value.trim()); + const token = value.trim(); + if (!token) return; + setBusy(true); + setError(null); + try { + await login(token); + setLoggedIn(true); window.location.href = '/admin'; + } catch (e) { + if (e instanceof ApiError && e.status === 401) { + setError('Invalid token.'); + } else { + setError('Login failed. Try again.'); + } + setBusy(false); } } @@ -27,12 +42,22 @@ export default function Login() { required value={value} onChange={e => setValue(e.target.value)} + autoComplete="current-password" className="w-full bg-crust border border-surface1 rounded-lg px-4 py-3 text-text focus:outline-none focus:border-mauve transition-colors" placeholder="••••••••••••" />
-
diff --git a/frontend/src/layouts/AdminLayout.astro b/frontend/src/layouts/AdminLayout.astro index d3e3116..7fe9833 100644 --- a/frontend/src/layouts/AdminLayout.astro +++ b/frontend/src/layouts/AdminLayout.astro @@ -10,10 +10,29 @@ const { title, wide = false } = Astro.props; --- -