feat: add health checks, OG image generation, and R2 integration

- Implement health check system with caching and singleflight pattern
- Add OG image generation via Satori with R2 storage backend
- Configure Railway deployment with health check endpoint
- Add connection pooling and Unix socket support for Bun SSR
- Block external access to internal routes (/internal/*)
This commit is contained in:
2026-01-05 03:16:55 -06:00
parent 9de3c84f00
commit 81d9541b44
27 changed files with 2183 additions and 127 deletions
Vendored
+1
View File
@@ -1,3 +1,4 @@
.env*
web/node_modules/
target/
.vscode/
Generated
+1135 -37
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -4,8 +4,11 @@ version = "0.1.0"
edition = "2024"
[dependencies]
aws-config = "1.8.12"
aws-sdk-s3 = "1.119.0"
axum = "0.8.8"
clap = { version = "4.5.54", features = ["derive", "env"] }
futures = "0.3.31"
include_dir = "0.7.4"
mime_guess = "2.0.5"
nu-ansi-term = "0.50.3"
+4 -3
View File
@@ -39,8 +39,9 @@ WORKDIR /build
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
# Build frontend
# Build frontend with environment variables
COPY web/ ./
ARG VITE_OG_R2_BASE_URL
RUN bun run build
# ========== Stage 5: Final Rust Build (with embedded assets) ==========
@@ -88,9 +89,9 @@ cleanup() {
}
trap cleanup SIGTERM SIGINT
# Start Bun SSR (propagate LOG_JSON to Bun process)
# Start Bun SSR (propagate LOG_JSON and set UPSTREAM_URL)
cd /app/web/build
SOCKET_PATH=/tmp/bun.sock LOG_JSON="${LOG_JSON}" bun --preload /app/web/console-logger.js index.js &
SOCKET_PATH=/tmp/bun.sock LOG_JSON="${LOG_JSON}" UPSTREAM_URL=/tmp/api.sock bun --preload /app/web/console-logger.js index.js &
BUN_PID=$!
# Wait for Bun socket
+5 -1
View File
@@ -1,3 +1,5 @@
set dotenv-load
default:
just --list
@@ -31,4 +33,6 @@ docker-run port="8080":
just docker-run-json {{port}} | hl --config .hl.config.toml -P
docker-run-json port="8080":
docker run -p {{port}}:8080 xevion-dev
docker stop xevion-dev-container 2>/dev/null || true
docker rm xevion-dev-container 2>/dev/null || true
docker run --name xevion-dev-container -p {{port}}:8080 xevion-dev
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://railway.com/railway.schema.json",
"deploy": {
"healthcheckPath": "/api/health",
"healthcheckTimeout": 30
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
use axum::{
http::{header, StatusCode, Uri},
http::{StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use include_dir::{include_dir, Dir};
use include_dir::{Dir, include_dir};
static CLIENT_ASSETS: Dir = include_dir!("$CARGO_MANIFEST_DIR/web/build/client");
+1 -1
View File
@@ -3,7 +3,7 @@ use serde::Serialize;
use serde_json::{Map, Value};
use std::fmt;
use time::macros::format_description;
use time::{format_description::FormatItem, OffsetDateTime};
use time::{OffsetDateTime, format_description::FormatItem};
use tracing::field::{Field, Visit};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::fmt::format::Writer;
+119
View File
@@ -0,0 +1,119 @@
use futures::future::{BoxFuture, FutureExt, Shared};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
/// The state of the health check system
enum HealthCheckState {
/// No check has ever been performed
Initial,
/// A check is currently in progress, all requests await this future
Checking {
future: Shared<BoxFuture<'static, bool>>,
},
/// We have a cached result from a completed check
Cached { healthy: bool, checked_at: Instant },
}
/// Inner state that can be shared across futures
struct HealthCheckerInner {
state: Mutex<HealthCheckState>,
had_success: AtomicBool,
}
/// Manages health check state with caching and singleflight behavior
pub struct HealthChecker {
inner: Arc<HealthCheckerInner>,
check_fn: Arc<dyn Fn() -> BoxFuture<'static, bool> + Send + Sync>,
}
impl HealthChecker {
/// Create a new health checker with the given check function
pub fn new<F, Fut>(check_fn: F) -> Self
where
F: Fn() -> Fut + Send + Sync + 'static,
Fut: std::future::Future<Output = bool> + Send + 'static,
{
Self {
inner: Arc::new(HealthCheckerInner {
state: Mutex::new(HealthCheckState::Initial),
had_success: AtomicBool::new(false),
}),
check_fn: Arc::new(move || check_fn().boxed()),
}
}
/// Perform a health check with caching and singleflight behavior
pub async fn check(&self) -> bool {
let mut state = self.inner.state.lock().await;
match &*state {
HealthCheckState::Initial => {
// Start first check, transition to Checking
let future = self.create_check_future();
*state = HealthCheckState::Checking {
future: future.clone(),
};
drop(state);
future.await
}
HealthCheckState::Checking { future } => {
// Join existing check (singleflight)
let future = future.clone();
drop(state);
future.await
}
HealthCheckState::Cached {
healthy,
checked_at,
} => {
// Determine cache window based on startup status
let window = if self.inner.had_success.load(Ordering::Relaxed) {
Duration::from_secs(15)
} else {
Duration::from_secs(1)
};
if checked_at.elapsed() < window {
// Serve from cache
return *healthy;
}
// Cache stale, start new check
let future = self.create_check_future();
*state = HealthCheckState::Checking {
future: future.clone(),
};
drop(state);
future.await
}
}
}
/// Create a shared future that performs the check and updates state
fn create_check_future(&self) -> Shared<BoxFuture<'static, bool>> {
let inner = Arc::clone(&self.inner);
let check_fn = Arc::clone(&self.check_fn);
async move {
let result = (check_fn)().await;
// Transition: Checking → Cached
*inner.state.lock().await = HealthCheckState::Cached {
healthy: result,
checked_at: Instant::now(),
};
if result {
inner.had_success.store(true, Ordering::Relaxed);
}
result
}
.boxed()
.shared()
}
}
+150 -26
View File
@@ -9,16 +9,21 @@ use clap::Parser;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tower_http::{cors::CorsLayer, limit::RequestBodyLimitLayer, trace::TraceLayer};
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
mod assets;
mod config;
mod formatter;
mod health;
mod middleware;
mod og;
mod r2;
use assets::serve_embedded_asset;
use config::{Args, ListenAddr};
use formatter::{CustomJsonFormatter, CustomPrettyFormatter};
use health::HealthChecker;
use middleware::RequestIdLayer;
fn init_tracing() {
@@ -69,14 +74,68 @@ async fn main() {
std::process::exit(1);
}
// Create HTTP client for TCP connections with optimized pool settings
let http_client = reqwest::Client::builder()
.pool_max_idle_per_host(8)
.pool_idle_timeout(Duration::from_secs(600)) // 10 minutes
.tcp_keepalive(Some(Duration::from_secs(60)))
.timeout(Duration::from_secs(5)) // Default timeout for SSR
.connect_timeout(Duration::from_secs(3))
.build()
.expect("Failed to create HTTP client");
// Create Unix socket client if downstream is a Unix socket
let unix_client = if args.downstream.starts_with('/') || args.downstream.starts_with("./") {
let path = PathBuf::from(&args.downstream);
Some(
reqwest::Client::builder()
.pool_max_idle_per_host(8)
.pool_idle_timeout(Duration::from_secs(600)) // 10 minutes
.timeout(Duration::from_secs(5)) // Default timeout for SSR
.connect_timeout(Duration::from_secs(3))
.unix_socket(path)
.build()
.expect("Failed to create Unix socket client"),
)
} else {
None
};
// Create health checker
let downstream_url_for_health = args.downstream.clone();
let http_client_for_health = http_client.clone();
let unix_client_for_health = unix_client.clone();
let health_checker = Arc::new(HealthChecker::new(move || {
let downstream_url = downstream_url_for_health.clone();
let http_client = http_client_for_health.clone();
let unix_client = unix_client_for_health.clone();
async move { perform_health_check(downstream_url, http_client, unix_client).await }
}));
let state = Arc::new(AppState {
downstream_url: args.downstream.clone(),
http_client,
unix_client,
health_checker,
});
// Regenerate common OGP images on startup
tokio::spawn({
let state = state.clone();
async move {
og::regenerate_common_images(state).await;
}
});
let app = Router::new()
.nest("/api", api_routes())
.route("/api/", any(api_root_404_handler))
.route("/_app/{*path}", axum::routing::get(serve_embedded_asset).head(serve_embedded_asset))
.route(
"/_app/{*path}",
axum::routing::get(serve_embedded_asset).head(serve_embedded_asset),
)
.fallback(isr_handler)
.layer(TraceLayer::new_for_http())
.layer(RequestIdLayer::new(args.trust_request_id.clone()))
@@ -131,19 +190,24 @@ async fn main() {
}
#[derive(Clone)]
struct AppState {
pub struct AppState {
downstream_url: String,
http_client: reqwest::Client,
unix_client: Option<reqwest::Client>,
health_checker: Arc<HealthChecker>,
}
#[derive(Debug)]
enum ProxyError {
pub enum ProxyError {
Network(reqwest::Error),
Other(String),
}
impl std::fmt::Display for ProxyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ProxyError::Network(e) => write!(f, "Network error: {}", e),
ProxyError::Other(s) => write!(f, "{}", s),
}
}
}
@@ -178,8 +242,14 @@ fn is_page_route(path: &str) -> bool {
fn api_routes() -> Router<Arc<AppState>> {
Router::new()
.route("/", any(api_root_404_handler))
.route("/health", axum::routing::get(health_handler).head(health_handler))
.route("/projects", axum::routing::get(projects_handler).head(projects_handler))
.route(
"/health",
axum::routing::get(health_handler).head(health_handler),
)
.route(
"/projects",
axum::routing::get(projects_handler).head(projects_handler),
)
.fallback(api_404_and_method_handler)
}
@@ -187,8 +257,14 @@ async fn api_root_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
api_404_handler(uri).await
}
async fn health_handler() -> impl IntoResponse {
async fn health_handler(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let healthy = state.health_checker.check().await;
if healthy {
(StatusCode::OK, "OK")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "Unhealthy")
}
}
async fn api_404_and_method_handler(req: Request) -> impl IntoResponse {
@@ -196,8 +272,12 @@ async fn api_404_and_method_handler(req: Request) -> impl IntoResponse {
let uri = req.uri();
let path = uri.path();
if method != axum::http::Method::GET && method != axum::http::Method::HEAD && method != axum::http::Method::OPTIONS {
let content_type = req.headers()
if method != axum::http::Method::GET
&& method != axum::http::Method::HEAD
&& method != axum::http::Method::OPTIONS
{
let content_type = req
.headers()
.get(axum::http::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok());
@@ -209,9 +289,13 @@ async fn api_404_and_method_handler(req: Request) -> impl IntoResponse {
"error": "Unsupported media type",
"message": "API endpoints only accept application/json"
})),
).into_response();
)
.into_response();
}
} else if method == axum::http::Method::POST || method == axum::http::Method::PUT || method == axum::http::Method::PATCH {
} else if method == axum::http::Method::POST
|| method == axum::http::Method::PUT
|| method == axum::http::Method::PATCH
{
// POST/PUT/PATCH require Content-Type header
return (
StatusCode::BAD_REQUEST,
@@ -219,7 +303,8 @@ async fn api_404_and_method_handler(req: Request) -> impl IntoResponse {
"error": "Missing Content-Type header",
"message": "Content-Type: application/json is required"
})),
).into_response();
)
.into_response();
}
}
@@ -231,7 +316,8 @@ async fn api_404_and_method_handler(req: Request) -> impl IntoResponse {
"error": "Not found",
"path": path
})),
).into_response()
)
.into_response()
}
async fn api_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
@@ -306,7 +392,7 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
let mut headers = HeaderMap::new();
headers.insert(
axum::http::header::ALLOW,
axum::http::HeaderValue::from_static("GET, HEAD, OPTIONS")
axum::http::HeaderValue::from_static("GET, HEAD, OPTIONS"),
);
return (
StatusCode::METHOD_NOT_ALLOWED,
@@ -320,14 +406,17 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
if path.starts_with("/api/") {
tracing::error!("API request reached ISR handler - routing bug!");
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal routing error",
)
.into_response();
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal routing error").into_response();
}
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./") {
// Block internal routes from external access
if path.starts_with("/internal/") {
tracing::warn!(path = %path, "Attempted access to internal route");
return (StatusCode::NOT_FOUND, "Not found").into_response();
}
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./")
{
if query.is_empty() {
format!("http://localhost{}", path)
} else {
@@ -415,14 +504,10 @@ async fn proxy_to_bun(
url: &str,
state: Arc<AppState>,
) -> Result<(StatusCode, HeaderMap, String), ProxyError> {
let client = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./") {
let path = PathBuf::from(&state.downstream_url);
reqwest::Client::builder()
.unix_socket(path)
.build()
.map_err(ProxyError::Network)?
let client = if state.unix_client.is_some() {
state.unix_client.as_ref().unwrap()
} else {
reqwest::Client::new()
&state.http_client
};
let response = client.get(url).send().await.map_err(ProxyError::Network)?;
@@ -450,3 +535,42 @@ async fn proxy_to_bun(
let body = response.text().await.map_err(ProxyError::Network)?;
Ok((status, headers, body))
}
async fn perform_health_check(
downstream_url: String,
http_client: reqwest::Client,
unix_client: Option<reqwest::Client>,
) -> bool {
let url = if downstream_url.starts_with('/') || downstream_url.starts_with("./") {
"http://localhost/internal/health".to_string()
} else {
format!("{}/internal/health", downstream_url)
};
let client = if unix_client.is_some() {
unix_client.as_ref().unwrap()
} else {
&http_client
};
match tokio::time::timeout(Duration::from_secs(5), client.get(&url).send()).await {
Ok(Ok(response)) => {
let is_success = response.status().is_success();
if !is_success {
tracing::warn!(
status = response.status().as_u16(),
"Health check failed: Bun returned non-success status"
);
}
is_success
}
Ok(Err(err)) => {
tracing::error!(error = %err, "Health check failed: cannot reach Bun");
false
}
Err(_) => {
tracing::error!("Health check failed: timeout after 5s");
false
}
}
}
+4 -7
View File
@@ -1,9 +1,4 @@
use axum::{
body::Body,
extract::Request,
http::HeaderName,
response::Response,
};
use axum::{body::Body, extract::Request, http::HeaderName, response::Response};
use std::task::{Context, Poll};
use tower::{Layer, Service};
@@ -44,7 +39,9 @@ where
{
type Response = S::Response;
type Error = S::Error;
type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>>;
type Future = std::pin::Pin<
Box<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
+112
View File
@@ -0,0 +1,112 @@
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use crate::{AppState, r2::R2Client};
/// Discriminated union matching TypeScript's OGImageSpec in web/src/lib/og-types.ts
///
/// IMPORTANT: Keep this in sync with the TypeScript definition.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
pub enum OGImageSpec {
Index,
Projects,
Project { id: String },
}
impl OGImageSpec {
/// Get the R2 storage key for this spec
pub fn r2_key(&self) -> String {
match self {
OGImageSpec::Index => "og/index.png".to_string(),
OGImageSpec::Projects => "og/projects.png".to_string(),
OGImageSpec::Project { id } => format!("og/project/{}.png", id),
}
}
}
/// Generate an OG image by calling Bun's internal endpoint and upload to R2
#[tracing::instrument(skip(state), fields(r2_key))]
pub async fn generate_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Result<(), String> {
let r2 = R2Client::get()
.await
.ok_or_else(|| "R2 client not available".to_string())?;
let r2_key = spec.r2_key();
tracing::Span::current().record("r2_key", &r2_key);
// Call Bun's internal endpoint
let bun_url = if state.downstream_url.starts_with('/') || state.downstream_url.starts_with("./")
{
"http://localhost/internal/ogp".to_string()
} else {
format!("{}/internal/ogp", state.downstream_url)
};
let client = state.unix_client.as_ref().unwrap_or(&state.http_client);
let response = client
.post(&bun_url)
.json(spec)
.timeout(std::time::Duration::from_secs(30))
.send()
.await
.map_err(|e| format!("Failed to call Bun: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(format!("Bun returned status {}: {}", status, error_text));
}
let bytes = response
.bytes()
.await
.map_err(|e| format!("Failed to read response: {}", e))?
.to_vec();
r2.put_object(&r2_key, bytes)
.await
.map_err(|e| format!("Failed to upload to R2: {}", e))?;
tracing::info!(r2_key, "OG image generated and uploaded");
Ok(())
}
/// Check if an OG image exists in R2
pub async fn og_image_exists(spec: &OGImageSpec) -> bool {
if let Some(r2) = R2Client::get().await {
r2.object_exists(&spec.r2_key()).await
} else {
false
}
}
/// Ensure an OG image exists, generating if necessary
pub async fn ensure_og_image(spec: &OGImageSpec, state: Arc<AppState>) -> Result<(), String> {
if og_image_exists(spec).await {
tracing::debug!(r2_key = spec.r2_key(), "OG image already exists");
return Ok(());
}
generate_og_image(spec, state).await
}
/// Regenerate common OG images (index, projects) on server startup
pub async fn regenerate_common_images(state: Arc<AppState>) {
tracing::info!("Regenerating common OG images");
let specs = vec![OGImageSpec::Index, OGImageSpec::Projects];
for spec in specs {
match generate_og_image(&spec, state.clone()).await {
Ok(()) => {
tracing::info!(r2_key = spec.r2_key(), "Successfully regenerated OG image");
}
Err(e) => {
tracing::error!(r2_key = spec.r2_key(), error = %e, "Failed to regenerate OG image");
}
}
}
tracing::info!("Finished regenerating common OG images");
}
+104
View File
@@ -0,0 +1,104 @@
use aws_config::BehaviorVersion;
use aws_sdk_s3::{
Client,
config::{Credentials, Region},
primitives::ByteStream,
};
use std::sync::Arc;
use tokio::sync::OnceCell;
static R2_CLIENT: OnceCell<Arc<R2Client>> = OnceCell::const_new();
pub struct R2Client {
client: Client,
bucket: String,
}
impl R2Client {
pub async fn new() -> Result<Self, String> {
let account_id =
std::env::var("R2_ACCOUNT_ID").map_err(|_| "R2_ACCOUNT_ID not set".to_string())?;
let access_key_id = std::env::var("R2_ACCESS_KEY_ID")
.map_err(|_| "R2_ACCESS_KEY_ID not set".to_string())?;
let secret_access_key = std::env::var("R2_SECRET_ACCESS_KEY")
.map_err(|_| "R2_SECRET_ACCESS_KEY not set".to_string())?;
let bucket = std::env::var("R2_BUCKET").map_err(|_| "R2_BUCKET not set".to_string())?;
let endpoint = format!("https://{}.r2.cloudflarestorage.com", account_id);
let credentials_provider =
Credentials::new(access_key_id, secret_access_key, None, None, "static");
let config = aws_config::defaults(BehaviorVersion::latest())
.region(Region::new("auto"))
.endpoint_url(endpoint)
.credentials_provider(credentials_provider)
.load()
.await;
let client = Client::new(&config);
Ok(Self { client, bucket })
}
pub async fn get() -> Option<Arc<R2Client>> {
R2_CLIENT
.get_or_try_init(|| async {
match R2Client::new().await {
Ok(client) => Ok(Arc::new(client)),
Err(e) => {
tracing::warn!(error = %e, "Failed to initialize R2 client, OG images will not be cached");
Err(e)
}
}
})
.await
.ok()
.cloned()
}
pub async fn get_object(&self, key: &str) -> Result<Vec<u8>, String> {
let result = self
.client
.get_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.map_err(|e| format!("Failed to get object from R2: {}", e))?;
let bytes = result
.body
.collect()
.await
.map_err(|e| format!("Failed to read object body: {}", e))?
.into_bytes()
.to_vec();
Ok(bytes)
}
pub async fn put_object(&self, key: &str, body: Vec<u8>) -> Result<(), String> {
self.client
.put_object()
.bucket(&self.bucket)
.key(key)
.body(ByteStream::from(body))
.content_type("image/png")
.send()
.await
.map_err(|e| format!("Failed to put object to R2: {}", e))?;
Ok(())
}
pub async fn object_exists(&self, key: &str) -> bool {
self.client
.head_object()
.bucket(&self.bucket)
.key(key)
.send()
.await
.is_ok()
}
}
+86 -4
View File
@@ -4,7 +4,8 @@
"workspaces": {
"": {
"dependencies": {
"@fontsource-variable/inter": "^5.1.0",
"@ethercorps/sveltekit-og": "^4.2.1",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/hanken-grotesk": "^5.1.0",
"@fontsource/schibsted-grotesk": "^5.2.8",
"@logtape/logtape": "^1.3.5",
@@ -14,6 +15,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@fontsource/inter": "^5.2.8",
"@iconify/json": "^2.2.424",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@@ -27,7 +29,7 @@
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6",
"svelte-adapter-bun": "^1.0.1",
"svelte-adapter-bun": "npm:@xevion/svelte-adapter-bun@^1.0.1",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.11",
"typescript-eslint": "^8.51.0",
@@ -115,6 +117,8 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@ethercorps/sveltekit-og": ["@ethercorps/sveltekit-og@4.2.1", "", { "dependencies": { "@resvg/resvg-wasm": "^2.6.2", "@takumi-rs/helpers": "^0.55.0", "@takumi-rs/image-response": "^0.55.0", "@takumi-rs/wasm": "^0.55.0", "satori": "^0.10.14", "satori-html": "0.3.2", "std-env": "^3.9.0", "unwasm": "^0.5.0" }, "peerDependencies": { "@sveltejs/kit": ">=2.0.0" } }, "sha512-mMkoKWMMBXL5iAYrMZqklezZDUU7HpHd+sNsz78e4gElXFyxdOnsIFfPPXpqDcUn6orZHs5MGHvtPi5II5xNAA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
@@ -125,6 +129,8 @@
"@fontsource/hanken-grotesk": ["@fontsource/hanken-grotesk@5.2.8", "", {}, "sha512-J/e6hdfNCbyc4WK5hmZtk0zjaIsFx3pvCdPVxY25iYw2C9v1ZggGz4nfHnRjMhcz4WfaadUuwLNtvj8sQ70tkg=="],
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@fontsource/schibsted-grotesk": ["@fontsource/schibsted-grotesk@5.2.8", "", {}, "sha512-CyyDW5aS89oKGFAVndOsJTQ5pqzKuPnSKWjrdJdMT5TD/eA2JyWapUBhvy6X/lqqrB/GNk74PIff7coPifeVyg=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -163,6 +169,8 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@resvg/resvg-wasm": ["@resvg/resvg-wasm@2.6.2", "", {}, "sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mp0/gqiPdepHjjVm7e0yL1acWvI0rJVVFQEADSezvAjon9sjQ7CEg9JnXICD4B1YrPmN9qV/e7cQZCp87tTV4w=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "darwin", "cpu": "x64" }, "sha512-40re4rMNrsi57oavRzIOpRGmg3QRlW6Ea8Q3znaqgOuJuKVrrm2bIQInTfkZJG7a4/5YMX7T951d0+toGLTdCA=="],
@@ -233,6 +241,8 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
"@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.8", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA=="],
@@ -275,6 +285,30 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@takumi-rs/core": ["@takumi-rs/core@0.55.4", "", { "optionalDependencies": { "@takumi-rs/core-darwin-arm64": "0.55.4", "@takumi-rs/core-darwin-x64": "0.55.4", "@takumi-rs/core-linux-arm64-gnu": "0.55.4", "@takumi-rs/core-linux-arm64-musl": "0.55.4", "@takumi-rs/core-linux-x64-gnu": "0.55.4", "@takumi-rs/core-linux-x64-musl": "0.55.4", "@takumi-rs/core-win32-arm64-msvc": "0.55.4", "@takumi-rs/core-win32-x64-msvc": "0.55.4" } }, "sha512-+zB9r5pzRDDMTonwOgywG+SR3Ydsl7jOJef233Wo2pwcakcfjntgI3O+iEZthWuD8OK16Dhj5+JmG8B3mqBh+w=="],
"@takumi-rs/core-darwin-arm64": ["@takumi-rs/core-darwin-arm64@0.55.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LH/X/ul19DActLGcBpXnxH3OBEq8qOgPD56hNHAJMbnCRxAO6TDaIh2U7WqPVliSkFk3jZfikbD21SIEpZrp8A=="],
"@takumi-rs/core-darwin-x64": ["@takumi-rs/core-darwin-x64@0.55.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-UW7ovR/D1Qp8n8bJOo6JLqZZUDFWWtGRXEZZUZhzUeMSzJ4k3C6ef/DEc75bUTGeBKqCeypMPcvtkQAjcVwwhw=="],
"@takumi-rs/core-linux-arm64-gnu": ["@takumi-rs/core-linux-arm64-gnu@0.55.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-y1d5yuPapKlmt77TpE+XrtULj7LZ51leBqWSg6qMNKxhpvRqmjI/SYjHmk5YvshnrTkdKmRQiXJiiN5EzOhbmA=="],
"@takumi-rs/core-linux-arm64-musl": ["@takumi-rs/core-linux-arm64-musl@0.55.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-VRbQqbMeoPlrMmaqPwn30Sw82LYya+o4ru9dqV/7BKExozWj/pX9ahexlJdHsZ6wqmsr+ZxexZivK1mPum9ang=="],
"@takumi-rs/core-linux-x64-gnu": ["@takumi-rs/core-linux-x64-gnu@0.55.4", "", { "os": "linux", "cpu": "x64" }, "sha512-ecCUtNgOe6mCWKf+SE7cbJXWd6D6TQoCnKZAJAGrJkJLAdy/gBhCFhOyPz8M7q/4uWHUATentqi35KAp+jxBiQ=="],
"@takumi-rs/core-linux-x64-musl": ["@takumi-rs/core-linux-x64-musl@0.55.4", "", { "os": "linux", "cpu": "x64" }, "sha512-YBM2zPrGE/1sfHoFZvOsCvCuK9PfaxzePN/GnnlaAvpvgeRHiAU4PJkLGDpjMFfsWUAEdjly/b0HSAjVQ7NL6Q=="],
"@takumi-rs/core-win32-arm64-msvc": ["@takumi-rs/core-win32-arm64-msvc@0.55.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-VcgLCWnmyWuhwLv0Tpob8Hv5IFPreFVykoHruPGwXDVVoUcCo+lQ8oCO5EYTB8B/tBAXl2S0xUL0nMDbyLzMxQ=="],
"@takumi-rs/core-win32-x64-msvc": ["@takumi-rs/core-win32-x64-msvc@0.55.4", "", { "os": "win32", "cpu": "x64" }, "sha512-ta9g1gUybS2V4mHaccJHcMeBb+w1P6pgZuqHzLoQzBIEK9a/KncHPfnR48cz4sGfg4atorfSa6UBffa2FqijyQ=="],
"@takumi-rs/helpers": ["@takumi-rs/helpers@0.55.4", "", {}, "sha512-Q+iol0en/Az377+iox/jocJKUZ5JJK3R7yMtRI7zWgxXaOWkUspdwy66a3YC9pqlDszcM/YB5xMgbFEbn5wlPQ=="],
"@takumi-rs/image-response": ["@takumi-rs/image-response@0.55.4", "", { "dependencies": { "@takumi-rs/core": "0.55.4", "@takumi-rs/helpers": "0.55.4", "@takumi-rs/wasm": "0.55.4" } }, "sha512-E7IfI4Y01UK4I95Jq1/BkLaIWIoLT5bn5D5yPvcweSxMXZxpPMcukSWWmNFDboH+p9lj9ozjME75cf9kRdn9/w=="],
"@takumi-rs/wasm": ["@takumi-rs/wasm@0.55.4", "", {}, "sha512-/iOhQW+nJW0hhv2viu6806JehiAKWFvJ4LXux6CW4XBpP1xWdr4H+VBS7OYMbQu/7XaPITyL7B10lSTtRUAHoA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -325,12 +359,16 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@0.0.8", "", {}, "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw=="],
"bits-ui": ["bits-ui@2.15.2", "", { "dependencies": { "@floating-ui/core": "^1.7.1", "@floating-ui/dom": "^1.7.1", "esm-env": "^1.1.2", "runed": "^0.35.1", "svelte-toolbelt": "^0.10.6", "tabbable": "^6.2.0" }, "peerDependencies": { "@internationalized/date": "^3.8.1", "svelte": "^5.33.0" } }, "sha512-S8eDbFkZCN17kZ7J9fD3MRXziV9ozjdFt2D3vTr2bvXCl7BtrIqguYt2U/zrFgLdR2erwybvCKv0JXYn8uKLDQ=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"camelize": ["camelize@1.0.1", "", {}, "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
@@ -353,6 +391,14 @@
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-background-parser": ["css-background-parser@0.1.0", "", {}, "sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA=="],
"css-box-shadow": ["css-box-shadow@1.0.0-3", "", {}, "sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg=="],
"css-color-keywords": ["css-color-keywords@1.0.0", "", {}, "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="],
"css-to-react-native": ["css-to-react-native@3.2.0", "", { "dependencies": { "camelize": "^1.0.0", "css-color-keywords": "^1.0.0", "postcss-value-parser": "^4.0.2" } }, "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -367,7 +413,7 @@
"devalue": ["devalue@5.6.1", "", {}, "sha512-jDwizj+IlEZBunHcOuuFVBnIMPAEHvTsJj0BcIp94xYguLRVBcXO853px/MyIJvbVzWdsGvrRweIUWJw8hBP7A=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
@@ -375,6 +421,8 @@
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
@@ -411,6 +459,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.7.4", "", {}, "sha512-5u2V/CDW15QM1XbbgS+0DfPxVB+jUKhWEKuuFuHncbk3tEEqzmoXL+2KyOFuKGqOnmdIy0/davWF1CkuwtibCw=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
@@ -431,6 +481,8 @@
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"hex-rgb": ["hex-rgb@4.3.0", "", {}, "sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -463,6 +515,8 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"knitwork": ["knitwork@1.3.0", "", {}, "sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw=="],
"known-css-properties": ["known-css-properties@0.37.0", "", {}, "sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -493,6 +547,8 @@
"lilconfig": ["lilconfig@2.1.0", "", {}, "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ=="],
"linebreak": ["linebreak@1.1.0", "", { "dependencies": { "base64-js": "0.0.8", "unicode-trie": "^2.0.0" } }, "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ=="],
"local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
@@ -527,8 +583,12 @@
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -551,6 +611,8 @@
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="],
@@ -577,6 +639,10 @@
"sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="],
"satori": ["satori@0.10.14", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-to-react-native": "^3.0.0", "emoji-regex": "^10.2.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-wasm-web": "^0.3.3" } }, "sha512-abovcqmwl97WKioxpkfuMeZmndB1TuDFY/R+FymrZyiGP+pMYomvgSzVPnbNMWHHESOPosVHGL352oFbdAnJcA=="],
"satori-html": ["satori-html@0.3.2", "", { "dependencies": { "ultrahtml": "^1.2.0" } }, "sha512-wjTh14iqADFKDK80e51/98MplTGfxz2RmIzh0GqShlf4a67+BooLywF17TvJPD6phO0Hxm7Mf1N5LtRYvdkYRA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
@@ -591,8 +657,12 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string.prototype.codepointat": ["string.prototype.codepointat@0.2.1", "", {}, "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
@@ -603,7 +673,7 @@
"svelte": ["svelte@5.46.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.5.0", "esm-env": "^1.2.1", "esrap": "^2.2.1", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA=="],
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
"svelte-adapter-bun": ["@xevion/svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-GNvS7TmgJk6Q5VA3JoyasRW21D/IeDMzVzSCaRSWaOhpdNoru0QeYUek5Bp+lsD51gxaeOdaXccyOQaB72dXOA=="],
"svelte-check": ["svelte-check@4.3.5", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q=="],
@@ -619,6 +689,8 @@
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
@@ -639,12 +711,18 @@
"ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
"ultrahtml": ["ultrahtml@1.6.0", "", {}, "sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unicode-trie": ["unicode-trie@2.0.0", "", { "dependencies": { "pako": "^0.2.5", "tiny-inflate": "^1.0.0" } }, "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ=="],
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
"unplugin-icons": ["unplugin-icons@22.5.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/utils": "^3.0.2", "debug": "^4.4.3", "local-pkg": "^1.1.2", "unplugin": "^2.3.10" }, "peerDependencies": { "@svgr/core": ">=7.0.0", "@svgx/core": "^1.0.1", "@vue/compiler-sfc": "^3.0.2 || ^2.7.0", "svelte": "^3.0.0 || ^4.0.0 || ^5.0.0", "vue-template-compiler": "^2.6.12", "vue-template-es2015-compiler": "^1.9.0" }, "optionalPeers": ["@svgr/core", "@svgx/core", "@vue/compiler-sfc", "svelte", "vue-template-compiler", "vue-template-es2015-compiler"] }, "sha512-MBlMtT5RuMYZy4TZgqUL2OTtOdTUVsS1Mhj6G1pEzMlFJlEnq6mhUfoIt45gBWxHcsOdXJDWLg3pRZ+YmvAVWQ=="],
"unwasm": ["unwasm@0.5.2", "", { "dependencies": { "exsolve": "^1.0.8", "knitwork": "^1.3.0", "magic-string": "^0.30.21", "mlly": "^1.8.0", "pathe": "^2.0.3", "pkg-types": "^2.3.0" } }, "sha512-uWhB7IXQjMC4530uVAeu0lzvYK6P3qHVnmmdQniBi48YybOLN/DqEzcP9BRGk1YTDG3rRWRD8me55nIYoTHyMg=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
@@ -671,6 +749,8 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -699,6 +779,8 @@
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
+4 -2
View File
@@ -13,7 +13,8 @@
"format": "prettier --write ."
},
"dependencies": {
"@fontsource-variable/inter": "^5.1.0",
"@ethercorps/sveltekit-og": "^4.2.1",
"@fontsource-variable/inter": "^5.2.8",
"@fontsource/hanken-grotesk": "^5.1.0",
"@fontsource/schibsted-grotesk": "^5.2.8",
"@logtape/logtape": "^1.3.5",
@@ -23,6 +24,7 @@
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@fontsource/inter": "^5.2.8",
"@iconify/json": "^2.2.424",
"@sveltejs/kit": "^2.21.0",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
@@ -36,7 +38,7 @@
"prettier": "^3.7.4",
"prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.45.6",
"svelte-adapter-bun": "^1.0.1",
"svelte-adapter-bun": "npm:@xevion/svelte-adapter-bun@^1.0.1",
"svelte-check": "^4.3.4",
"tailwindcss": "^4.1.11",
"typescript-eslint": "^8.51.0",
+10 -27
View File
@@ -27,46 +27,29 @@ export async function initLogger() {
const useJsonLogs =
process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1";
const sinkName = useJsonLogs ? "json" : "console";
const sink = useJsonLogs
? (record: LogRecord) => {
process.stdout.write(railwayFormatter(record));
}
: getConsoleSink();
try {
if (!useJsonLogs) {
await configure({
sinks: {
console: getConsoleSink(),
[sinkName]: sink,
},
filters: {},
loggers: [
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["console"],
sinks: [sinkName],
},
{
category: [],
lowestLevel: "debug",
sinks: ["console"],
},
],
});
return;
}
await configure({
sinks: {
json: (record: LogRecord) => {
process.stdout.write(railwayFormatter(record));
},
},
filters: {},
loggers: [
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["json"],
},
{
category: ["ssr"],
lowestLevel: "info",
sinks: ["json"],
sinks: [sinkName],
},
],
});
+39
View File
@@ -0,0 +1,39 @@
import { read } from "$app/server";
import { CustomFont, resolveFonts } from "@ethercorps/sveltekit-og/fonts";
import HankenGrotesk900 from "@fontsource/hanken-grotesk/files/hanken-grotesk-latin-900-normal.woff?url";
import SchibstedGrotesk400 from "@fontsource/schibsted-grotesk/files/schibsted-grotesk-latin-400-normal.woff?url";
import Inter500 from "@fontsource/inter/files/inter-latin-500-normal.woff?url";
/**
* Load fonts for OG image generation.
* Fonts are sourced from @fontsource packages and imported directly from node_modules.
* Must be called on each request (fonts can't be cached globally in server context).
*
* Note: Only WOFF/TTF/OTF formats are supported by Satori (not WOFF2).
*/
export async function loadOGFonts() {
const fonts = [
new CustomFont(
"Hanken Grotesk",
() => read(HankenGrotesk900).arrayBuffer(),
{
weight: 900,
style: "normal",
},
),
new CustomFont(
"Schibsted Grotesk",
() => read(SchibstedGrotesk400).arrayBuffer(),
{
weight: 400,
style: "normal",
},
),
new CustomFont("Inter", () => read(Inter500).arrayBuffer(), {
weight: 500,
style: "normal",
}),
];
return await resolveFonts(fonts);
}
+116
View File
@@ -0,0 +1,116 @@
/**
* Generate OG image HTML template matching xevion.dev dark aesthetic.
* Satori only supports flex layouts and subset of CSS.
*/
export function generateOGTemplate({
title,
subtitle,
type = "default",
}: {
title: string;
subtitle?: string;
type?: "default" | "project";
}): string {
return `
<div
style="
display: flex;
width: 1200px;
height: 630px;
background-color: #000000;
color: #fafafa;
font-family: 'Schibsted Grotesk', sans-serif;
padding: 60px 80px;
"
>
<div
style="
display: flex;
flex-direction: column;
justify-content: space-between;
width: 100%;
height: 100%;
"
>
<!-- Main Content -->
<div style="display: flex; flex-direction: column; flex: 1; justify-content: center;">
<h1
style="
font-family: 'Hanken Grotesk', sans-serif;
font-weight: 900;
font-size: ${type === "project" ? "72px" : "96px"};
line-height: 1.1;
margin: 0;
color: #ffffff;
"
>
${escapeHtml(title)}
</h1>
${
subtitle
? `
<p
style="
font-family: 'Schibsted Grotesk', sans-serif;
font-size: 36px;
margin: 32px 0 0 0;
color: #a1a1aa;
line-height: 1.4;
"
>
${escapeHtml(subtitle)}
</p>
`
: ""
}
</div>
<!-- Footer -->
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-end;
border-top: 2px solid #27272a;
padding-top: 24px;
"
>
<div
style="
font-size: 28px;
color: #71717a;
font-weight: 500;
"
>
xevion.dev
</div>
${
type === "project"
? `
<div
style="
font-size: 24px;
color: #52525b;
text-transform: uppercase;
letter-spacing: 0.05em;
"
>
PROJECT
</div>
`
: ""
}
</div>
</div>
</div>
`;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Discriminated union of all OG image types.
*
* IMPORTANT: Keep in sync with Rust's OGImageSpec in src/og.rs
*/
export type OGImageSpec =
| { type: "index" }
| { type: "projects" }
| { type: "project"; id: string };
/**
* Generate the R2 public URL for an OG image.
* Called at ISR/build time when generating page metadata.
*
* @param spec - The OG image specification
* @returns Full URL to the R2-hosted image
*/
export function getOGImageUrl(spec: OGImageSpec): string {
const R2_BASE = import.meta.env.VITE_OG_R2_BASE_URL;
if (!R2_BASE) {
throw new Error("VITE_OG_R2_BASE_URL environment variable is not set");
}
switch (spec.type) {
case "index":
return `${R2_BASE}/og/index.png`;
case "projects":
return `${R2_BASE}/og/projects.png`;
case "project":
return `${R2_BASE}/og/project/${spec.id}.png`;
}
}
+14
View File
@@ -0,0 +1,14 @@
import type { LayoutServerLoad } from "./$types";
import { getOGImageUrl } from "$lib/og-types";
export const load: LayoutServerLoad = async ({ url }) => {
return {
metadata: {
title: "Xevion.dev",
description:
"The personal website of Xevion, a full-stack software developer.",
ogImage: getOGImageUrl({ type: "index" }),
url: url.toString(),
},
};
};
+28 -6
View File
@@ -6,16 +6,38 @@
import "@fontsource/schibsted-grotesk/500.css";
import "@fontsource/schibsted-grotesk/600.css";
let { children } = $props();
let { children, data } = $props();
const metadata = data?.metadata ?? {
title: "Xevion.dev",
description:
"The personal website of Xevion, a full-stack software developer.",
ogImage: "/api/og/home.png",
url: "https://xevion.dev",
};
</script>
<svelte:head>
<link rel="icon" href="/favicon.ico" />
<title>Xevion.dev</title>
<meta
name="description"
content="The personal website of Xevion, a full-stack software developer."
/>
<!-- Primary Meta Tags -->
<title>{metadata.title}</title>
<meta name="description" content={metadata.description} />
<!-- Open Graph Meta Tags -->
<meta property="og:type" content="website" />
<meta property="og:url" content={metadata.url} />
<meta property="og:title" content={metadata.title} />
<meta property="og:description" content={metadata.description} />
<meta property="og:image" content={metadata.ogImage} />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<!-- Twitter Card Meta Tags -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={metadata.title} />
<meta name="twitter:description" content={metadata.description} />
<meta name="twitter:image" content={metadata.ogImage} />
</svelte:head>
{@render children()}
+41
View File
@@ -0,0 +1,41 @@
import type { RequestHandler } from "./$types";
import { apiFetch } from "$lib/api";
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["ssr", "routes", "internal", "health"]);
/**
* Internal health check endpoint.
* Called by Rust server to validate full round-trip connectivity.
*
* IMPORTANT: This endpoint should never be accessible externally.
* It's blocked by the Rust ISR handler's /internal/* check.
*/
export const GET: RequestHandler = async () => {
try {
// Test connectivity to Rust API by fetching projects
// Use a 5 second timeout for this health check
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const projects = await apiFetch("/api/projects", {
signal: controller.signal,
});
clearTimeout(timeoutId);
// Validate response shape
if (!Array.isArray(projects)) {
logger.error("Health check failed: /api/projects returned non-array");
return new Response("Internal health check failed", { status: 503 });
}
logger.debug("Health check passed", { projectCount: projects.length });
return new Response("OK", { status: 200 });
} catch (error) {
logger.error("Health check failed", {
error: error instanceof Error ? error.message : String(error),
});
return new Response("Internal health check failed", { status: 503 });
}
};
+144
View File
@@ -0,0 +1,144 @@
import { ImageResponse } from "@ethercorps/sveltekit-og";
import type { RequestHandler } from "./$types";
import { loadOGFonts } from "$lib/og-fonts";
import { generateOGTemplate } from "$lib/og-template";
import { apiFetch } from "$lib/api";
import type { Project } from "../../projects/+page.server";
import type { OGImageSpec } from "$lib/og-types";
import { getLogger } from "@logtape/logtape";
const logger = getLogger(["ssr", "routes", "internal", "ogp"]);
/**
* Internal endpoint for OG image generation.
* Called by Rust server via POST with OGImageSpec JSON body.
*
* IMPORTANT: This endpoint should never be accessible externally.
* It's blocked by the Rust ISR handler's /internal/* check.
*/
export const POST: RequestHandler = async ({ request }) => {
let spec: OGImageSpec;
try {
spec = await request.json();
} catch {
logger.warn("Invalid JSON body received");
return new Response("Invalid JSON body", { status: 400 });
}
return await generateOGImage(spec);
};
/**
* GET handler for OG image generation using query parameters.
* Supports: ?type=index, ?type=projects, ?type=project&id=<id>
*/
export const GET: RequestHandler = async ({ url }) => {
const type = url.searchParams.get("type");
if (!type) {
logger.warn("Missing 'type' query parameter");
return new Response("Missing 'type' query parameter", { status: 400 });
}
let spec: OGImageSpec;
switch (type) {
case "index":
spec = { type: "index" };
break;
case "projects":
spec = { type: "projects" };
break;
case "project": {
const id = url.searchParams.get("id");
if (!id) {
logger.warn("Missing 'id' query parameter for project type");
return new Response("Missing 'id' query parameter for project type", {
status: 400,
});
}
spec = { type: "project", id };
break;
}
default:
logger.warn("Invalid 'type' query parameter", { type });
return new Response(`Invalid 'type' query parameter: ${type}`, {
status: 400,
});
}
return await generateOGImage(spec);
};
async function generateOGImage(spec: OGImageSpec): Promise<Response> {
logger.info("Generating OG image", { spec });
const templateData = await getTemplateData(spec);
try {
const fonts = await loadOGFonts();
const html = generateOGTemplate(templateData);
const imageResponse = new ImageResponse(html, {
width: 1200,
height: 630,
fonts,
});
const imageBuffer = await imageResponse.arrayBuffer();
logger.info("OG image generated successfully", { spec });
return new Response(imageBuffer, {
status: 200,
headers: { "Content-Type": "image/png" },
});
} catch (error) {
logger.error("OG image generation failed", {
spec,
error: error instanceof Error ? error.message : String(error),
});
return new Response("Failed to generate image", { status: 500 });
}
}
async function getTemplateData(spec: OGImageSpec): Promise<{
title: string;
subtitle?: string;
type?: "default" | "project";
}> {
switch (spec.type) {
case "index":
return {
title: "Ryan Walters",
subtitle: "Full-Stack Software Engineer",
type: "default",
};
case "projects":
return {
title: "Projects",
subtitle: "created, maintained, or contributed to by me...",
type: "default",
};
case "project":
try {
const projects = await apiFetch<Project[]>("/api/projects");
const project = projects.find((p) => p.id === spec.id);
if (project) {
return {
title: project.name,
subtitle: project.shortDescription,
type: "project",
};
}
} catch (error) {
logger.error("Failed to fetch project", { id: spec.id, error });
}
return {
title: "Project",
subtitle: "View on xevion.dev",
type: "project",
};
}
}
+8 -1
View File
@@ -1,5 +1,6 @@
import type { PageServerLoad } from "./$types";
import { apiFetch } from "$lib/api";
import { getOGImageUrl } from "$lib/og-types";
interface ProjectLink {
url: string;
@@ -14,9 +15,15 @@ export interface Project {
links: ProjectLink[];
}
export const load: PageServerLoad = async () => {
export const load: PageServerLoad = async ({ url }) => {
const projects = await apiFetch<Project[]>("/api/projects");
return {
projects,
metadata: {
title: "Projects | Xevion.dev",
description: "...",
ogImage: getOGImageUrl({ type: "projects" }),
url: url.toString(),
},
};
};
+1 -1
View File
@@ -4,7 +4,7 @@ import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
inlineStyleThreshold: 1000,
kit: {
adapter: adapter({
out: "build",
+1 -1
View File
@@ -55,7 +55,7 @@ export function jsonLogger(): Plugin {
sinks: ["json"],
},
{
category: ["vite"],
category: [],
lowestLevel: "debug",
sinks: ["json"],
},
+3
View File
@@ -2,6 +2,7 @@ import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import Icons from "unplugin-icons/vite";
import { sveltekitOG } from "@ethercorps/sveltekit-og/plugin";
import { jsonLogger } from "./vite-plugin-json-logger";
export default defineConfig({
@@ -9,7 +10,9 @@ export default defineConfig({
jsonLogger(),
tailwindcss(),
sveltekit(),
sveltekitOG(),
Icons({ compiler: "svelte" }),
],
clearScreen: false,
assetsInclude: ["**/*.wasm"],
});