mirror of
https://github.com/Xevion/xevion.dev.git
synced 2026-01-31 04:26:43 -06:00
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:
Vendored
+1
@@ -1,3 +1,4 @@
|
||||
.env*
|
||||
web/node_modules/
|
||||
target/
|
||||
.vscode/
|
||||
|
||||
Generated
+1135
-37
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://railway.com/railway.schema.json",
|
||||
"deploy": {
|
||||
"healthcheckPath": "/api/health",
|
||||
"healthcheckTimeout": 30
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
+156
-32
@@ -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,20 +257,30 @@ async fn api_root_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
|
||||
api_404_handler(uri).await
|
||||
}
|
||||
|
||||
async fn health_handler() -> impl IntoResponse {
|
||||
(StatusCode::OK, "OK")
|
||||
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 {
|
||||
let method = req.method();
|
||||
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());
|
||||
|
||||
|
||||
if let Some(ct) = content_type {
|
||||
if !ct.starts_with("application/json") {
|
||||
return (
|
||||
@@ -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,10 +303,11 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Route not found
|
||||
tracing::warn!(path = %path, method = %method, "API route not found");
|
||||
(
|
||||
@@ -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 {
|
||||
@@ -239,7 +325,7 @@ async fn api_404_handler(uri: axum::http::Uri) -> impl IntoResponse {
|
||||
.uri(uri)
|
||||
.body(axum::body::Body::empty())
|
||||
.unwrap();
|
||||
|
||||
|
||||
api_404_and_method_handler(req).await
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -315,19 +401,22 @@ async fn isr_handler(State(state): State<Arc<AppState>>, req: Request) -> Respon
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
|
||||
let is_head = method == axum::http::Method::HEAD;
|
||||
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
+12
-29
@@ -27,46 +27,29 @@ export async function initLogger() {
|
||||
const useJsonLogs =
|
||||
process.env.LOG_JSON === "true" || process.env.LOG_JSON === "1";
|
||||
|
||||
try {
|
||||
if (!useJsonLogs) {
|
||||
await configure({
|
||||
sinks: {
|
||||
console: getConsoleSink(),
|
||||
},
|
||||
filters: {},
|
||||
loggers: [
|
||||
{
|
||||
category: ["logtape", "meta"],
|
||||
lowestLevel: "warning",
|
||||
sinks: ["console"],
|
||||
},
|
||||
{
|
||||
category: [],
|
||||
lowestLevel: "debug",
|
||||
sinks: ["console"],
|
||||
},
|
||||
],
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sinkName = useJsonLogs ? "json" : "console";
|
||||
const sink = useJsonLogs
|
||||
? (record: LogRecord) => {
|
||||
process.stdout.write(railwayFormatter(record));
|
||||
}
|
||||
: getConsoleSink();
|
||||
|
||||
try {
|
||||
await configure({
|
||||
sinks: {
|
||||
json: (record: LogRecord) => {
|
||||
process.stdout.write(railwayFormatter(record));
|
||||
},
|
||||
[sinkName]: sink,
|
||||
},
|
||||
filters: {},
|
||||
loggers: [
|
||||
{
|
||||
category: ["logtape", "meta"],
|
||||
lowestLevel: "warning",
|
||||
sinks: ["json"],
|
||||
sinks: [sinkName],
|
||||
},
|
||||
{
|
||||
category: ["ssr"],
|
||||
lowestLevel: "info",
|
||||
sinks: ["json"],
|
||||
category: [],
|
||||
lowestLevel: "debug",
|
||||
sinks: [sinkName],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -55,7 +55,7 @@ export function jsonLogger(): Plugin {
|
||||
sinks: ["json"],
|
||||
},
|
||||
{
|
||||
category: ["vite"],
|
||||
category: [],
|
||||
lowestLevel: "debug",
|
||||
sinks: ["json"],
|
||||
},
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user