Compare commits

..

2 commits

Author SHA1 Message Date
Jet
7b842b3342
feat: add tor service and style
feat: add tor service
2026-03-18 17:04:48 -07:00
Jet Pham
f48390b15e feat: add project and email service 2026-03-18 13:17:33 -07:00
35 changed files with 2813 additions and 66 deletions

8
.gitignore vendored
View file

@ -29,3 +29,11 @@ yarn-error.log*
/.direnv
/result
# rust
/api/target
# sqlite
*.db
*.db-wal
*.db-shm

1606
api/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

13
api/Cargo.toml Normal file
View file

@ -0,0 +1,13 @@
[package]
name = "jetpham-qa-api"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.8"
lettre = "0.11"
rusqlite = { version = "0.32", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["cors"] }

61
api/src/email.rs Normal file
View file

@ -0,0 +1,61 @@
use lettre::message::Mailbox;
use lettre::transport::smtp::client::Tls;
use lettre::{Message, SmtpTransport, Transport};
pub fn send_notification(
id: i64,
question: &str,
notify_email: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let truncated = if question.len() > 50 {
format!("{}...", &question[..50])
} else {
question.to_string()
};
let from: Mailbox = "Q&A <qa@extremist.software>".parse()?;
let reply_to: Mailbox = format!("qa+{id}@extremist.software").parse()?;
let to: Mailbox = notify_email.parse()?;
let email = Message::builder()
.from(from)
.reply_to(reply_to)
.to(to)
.subject(format!("Q&A #{id}: {truncated}"))
.body(question.to_string())?;
let mailer = SmtpTransport::builder_dangerous("localhost")
.tls(Tls::None)
.build();
mailer.send(&email)?;
Ok(())
}
pub fn strip_quoted_text(body: &str) -> String {
let mut result = Vec::new();
for line in body.lines() {
if line.starts_with('>') {
continue;
}
if line.starts_with("On ") && line.ends_with("wrote:") {
break;
}
result.push(line);
}
result.join("\n").trim().to_string()
}
pub fn extract_id_from_address(to: &str) -> Result<i64, Box<dyn std::error::Error>> {
let addr = to.trim();
let addr = if let Some(start) = addr.find('<') {
&addr[start + 1..addr.find('>').unwrap_or(addr.len())]
} else {
addr
};
let local = addr.split('@').next().unwrap_or("");
let id_str = local
.strip_prefix("qa+")
.ok_or("No qa+ prefix in address")?;
Ok(id_str.parse()?)
}

184
api/src/handlers.rs Normal file
View file

@ -0,0 +1,184 @@
use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::email;
use crate::serve::AppState;
#[derive(Serialize)]
pub struct Question {
id: i64,
question: String,
answer: String,
created_at: String,
answered_at: String,
}
pub async fn get_questions(
State(state): State<Arc<AppState>>,
) -> Result<Json<Vec<Question>>, StatusCode> {
let db = state.db.lock().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let mut stmt = db
.prepare(
"SELECT id, question, answer, created_at, answered_at \
FROM questions WHERE answer IS NOT NULL \
ORDER BY answered_at DESC",
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let questions = stmt
.query_map([], |row| {
Ok(Question {
id: row.get(0)?,
question: row.get(1)?,
answer: row.get(2)?,
created_at: row.get(3)?,
answered_at: row.get(4)?,
})
})
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.collect::<Result<Vec<_>, _>>()
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(questions))
}
#[derive(Deserialize)]
pub struct SubmitQuestion {
question: String,
}
pub async fn post_question(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(body): Json<SubmitQuestion>,
) -> Result<StatusCode, (StatusCode, String)> {
if body.question.is_empty() || body.question.len() > 200 {
return Err((
StatusCode::BAD_REQUEST,
"Question must be 1-200 characters".to_string(),
));
}
let ip = headers
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string());
if !state.rate_limiter.check(&ip) {
return Err((
StatusCode::TOO_MANY_REQUESTS,
"Too many questions. Try again later.".to_string(),
));
}
let id: i64 = {
let db = state
.db
.lock()
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
db.execute(
"INSERT INTO questions (question) VALUES (?1)",
rusqlite::params![body.question],
)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "insert error".to_string()))?;
db.last_insert_rowid()
};
let notify_email = state.notify_email.clone();
let question_text = body.question.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) = email::send_notification(id, &question_text, &notify_email) {
eprintln!("Failed to send notification: {e}");
}
});
Ok(StatusCode::CREATED)
}
// --- MTA Hook webhook types ---
#[derive(Deserialize)]
pub struct MtaHookPayload {
#[serde(default)]
pub messages: Vec<MtaHookMessage>,
}
#[derive(Deserialize)]
pub struct MtaHookMessage {
#[serde(default)]
pub envelope: Envelope,
#[serde(default)]
pub contents: String,
}
#[derive(Deserialize, Default)]
pub struct Envelope {
#[serde(default)]
pub to: Vec<String>,
}
#[derive(Serialize)]
pub struct MtaHookResponse {
pub action: &'static str,
}
pub async fn webhook(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<MtaHookPayload>,
) -> Result<Json<MtaHookResponse>, (StatusCode, String)> {
// Verify webhook secret
let secret = headers
.get("X-Webhook-Secret")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
if secret != state.webhook_secret {
return Err((StatusCode::UNAUTHORIZED, "invalid secret".to_string()));
}
for message in &payload.messages {
// Find a qa+<id> recipient
let qa_recipient = message.envelope.to.iter().find(|addr| {
let local = addr.split('@').next().unwrap_or("");
local.starts_with("qa+")
});
let recipient = match qa_recipient {
Some(r) => r,
None => continue, // not a Q&A reply, skip
};
let id = match email::extract_id_from_address(recipient) {
Ok(id) => id,
Err(_) => continue,
};
let body = email::strip_quoted_text(&message.contents);
if body.is_empty() {
continue;
}
let db = state
.db
.lock()
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "db error".to_string()))?;
db.execute(
"UPDATE questions SET answer = ?1, answered_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') \
WHERE id = ?2 AND answer IS NULL",
rusqlite::params![body, id],
)
.map_err(|_| (StatusCode::INTERNAL_SERVER_ERROR, "update error".to_string()))?;
return Ok(Json(MtaHookResponse { action: "discard" }));
}
// No Q&A recipient matched — let Stalwart deliver normally
Ok(Json(MtaHookResponse { action: "accept" }))
}

9
api/src/main.rs Normal file
View file

@ -0,0 +1,9 @@
mod email;
mod handlers;
mod rate_limit;
mod serve;
#[tokio::main]
async fn main() {
serve::run().await.expect("server error");
}

35
api/src/rate_limit.rs Normal file
View file

@ -0,0 +1,35 @@
use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
pub struct RateLimiter {
max_requests: u32,
window_secs: u64,
clients: Mutex<HashMap<String, (u32, Instant)>>,
}
impl RateLimiter {
pub fn new(max_requests: u32, window_secs: u64) -> Self {
Self {
max_requests,
window_secs,
clients: Mutex::new(HashMap::new()),
}
}
pub fn check(&self, ip: &str) -> bool {
let mut clients = self.clients.lock().unwrap();
let now = Instant::now();
let entry = clients.entry(ip.to_string()).or_insert((0, now));
if now.duration_since(entry.1).as_secs() >= self.window_secs {
*entry = (1, now);
return true;
}
if entry.0 >= self.max_requests {
return false;
}
entry.0 += 1;
true
}
}

55
api/src/serve.rs Normal file
View file

@ -0,0 +1,55 @@
use std::sync::{Arc, Mutex};
use axum::routing::{get, post};
use axum::Router;
use rusqlite::Connection;
use tower_http::cors::CorsLayer;
use crate::handlers;
use crate::rate_limit::RateLimiter;
pub struct AppState {
pub db: Mutex<Connection>,
pub notify_email: String,
pub rate_limiter: RateLimiter,
pub webhook_secret: String,
}
pub async fn run() -> Result<(), Box<dyn std::error::Error>> {
let db_path = std::env::var("QA_DB_PATH").unwrap_or_else(|_| "qa.db".to_string());
let notify_email = std::env::var("QA_NOTIFY_EMAIL").expect("QA_NOTIFY_EMAIL must be set");
let webhook_secret = std::env::var("WEBHOOK_SECRET").expect("WEBHOOK_SECRET must be set");
let conn = Connection::open(&db_path)?;
conn.execute_batch("PRAGMA journal_mode=WAL;")?;
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question TEXT NOT NULL,
answer TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
answered_at TEXT
);",
)?;
let state = Arc::new(AppState {
db: Mutex::new(conn),
notify_email,
rate_limiter: RateLimiter::new(5, 3600),
webhook_secret,
});
let app = Router::new()
.route(
"/api/questions",
get(handlers::get_questions).post(handlers::post_question),
)
.route("/api/webhook", post(handlers::webhook))
.layer(CorsLayer::permissive())
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3001").await?;
println!("Listening on 127.0.0.1:3001");
axum::serve(listener, app).await?;
Ok(())
}

84
flake.lock generated
View file

@ -1,8 +1,53 @@
{
"nodes": {
"agenix": {
"inputs": {
"darwin": "darwin",
"home-manager": "home-manager",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1770165109,
"narHash": "sha256-9VnK6Oqai65puVJ4WYtCTvlJeXxMzAp/69HhQuTdl/I=",
"owner": "ryantm",
"repo": "agenix",
"rev": "b027ee29d959fda4b60b57566d64c98a202e0feb",
"type": "github"
},
"original": {
"owner": "ryantm",
"repo": "agenix",
"type": "github"
}
},
"darwin": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1744478979,
"narHash": "sha256-dyN+teG9G82G+m+PX/aSAagkC+vUv0SgUw3XkPhQodQ=",
"owner": "lnl7",
"repo": "nix-darwin",
"rev": "43975d782b418ebf4969e9ccba82466728c2851b",
"type": "github"
},
"original": {
"owner": "lnl7",
"ref": "master",
"repo": "nix-darwin",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
@ -18,6 +63,27 @@
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"agenix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1745494811,
"narHash": "sha256-YZCh2o9Ua1n9uCvrvi5pRxtuVNml8X2a03qIFfRKpFs=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "abfad3d2958c9e6300a883bd443512c55dfeb1be",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1772542754,
@ -52,6 +118,7 @@
},
"root": {
"inputs": {
"agenix": "agenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
@ -89,6 +156,21 @@
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",

View file

@ -4,8 +4,10 @@
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils";
agenix.url = "github:ryantm/agenix";
agenix.inputs.nixpkgs.follows = "nixpkgs";
};
outputs = { self, nixpkgs, rust-overlay, flake-utils }:
outputs = { self, nixpkgs, rust-overlay, flake-utils, agenix }:
(flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
@ -77,10 +79,20 @@
runHook postInstall
'';
};
qa-api = pkgs.rustPlatform.buildRustPackage {
pname = "jetpham-qa-api";
version = "0.1.0";
src = ./api;
cargoHash = "sha256-/EgiCn5N3E1tCcBWI3Sm3NGQt2h8l8yPwi/ZjporiVs=";
nativeBuildInputs = [ pkgs.pkg-config ];
buildInputs = [ pkgs.openssl ];
};
in {
packages = {
default = website;
cgol-wasm = cgol-wasm;
inherit qa-api;
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [

View file

@ -71,8 +71,8 @@
},
"sameAs": [
"https://github.com/jetpham",
"https://x.com/jetpham5",
"https://bsky.app/profile/jetpham.com",
"https://x.com/exmistsoftware",
"https://bsky.app/profile/extremist.software",
"https://git.extremist.software"
]
}
@ -80,62 +80,21 @@
</head>
<body style="background:#000">
<canvas id="canvas" class="fixed top-0 left-0 -z-10 h-screen w-screen" aria-hidden="true"></canvas>
<main>
<div class="flex flex-col items-center justify-start px-4">
<!-- FrostedBox -->
<div class="relative px-[2ch] py-[2ch] my-[2ch] w-full max-w-[66.666667%] min-w-fit md:mt-[4ch]">
<nav aria-label="Main navigation" class="flex justify-center px-4">
<div class="relative px-[2ch] py-[1ch] mt-[2ch] w-full max-w-[66.666667%] min-w-fit">
<div
class="pointer-events-none absolute inset-0 h-[200%]"
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%); -webkit-mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%);"
class="pointer-events-none absolute inset-0"
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);"
aria-hidden="true"
></div>
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
<!-- Content -->
<div class="relative z-10">
<div class="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
<div class="order-1 flex flex-col items-center md:order-2">
<h1 class="sr-only">Jet Pham</h1>
<div id="ansi-art" aria-hidden="true"></div>
<p class="mt-[2ch]">Software Extremist</p>
</div>
<div class="order-2 shrink-0 md:order-1">
<img
src="/jet.svg"
alt="A picture of Jet wearing a beanie in purple and blue lighting"
width="250"
height="250"
class="aspect-square w-full max-w-[250px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
style="background-color: #a80055; color: transparent"
/>
<div class="relative z-10 flex justify-center gap-[2ch]">
<a href="/">[HOME]</a>
<a href="/qa">[Q&A]</a>
</div>
</div>
<!-- Contact -->
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Contact</legend>
<a href="mailto:jet@extremist.software">jet@extremist.software</a>
</fieldset>
<!-- Links -->
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Links</legend>
<ol>
<li>
<a href="https://git.extremist.software" class="inline-flex items-center">Forgejo</a>
</li>
<li>
<a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a>
</li>
<li>
<a href="https://x.com/jetpham5" class="inline-flex items-center">X</a>
</li>
<li>
<a href="https://bsky.app/profile/jetpham.com" class="inline-flex items-center">Bluesky</a>
</li>
</ol>
</fieldset>
</div>
</div>
</div>
</main>
</nav>
<div id="outlet"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View file

@ -4,6 +4,7 @@ self:
let
cfg = config.services.jetpham-website;
package = self.packages.x86_64-linux.default;
qaApi = self.packages.x86_64-linux.qa-api;
in
{
options.services.jetpham-website = {
@ -14,15 +15,130 @@ in
default = "jetpham.com";
description = "Domain to serve the website on.";
};
tor.enable = lib.mkEnableOption "Tor hidden service for the website";
envFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Environment file containing QA_NOTIFY_EMAIL.";
};
qaMailDomain = lib.mkOption {
type = lib.types.str;
default = "extremist.software";
description = "Mail domain for Q&A reply addresses.";
};
webhookSecretFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "File containing the WEBHOOK_SECRET for MTA Hook authentication.";
};
};
config = lib.mkIf cfg.enable {
age.secrets.webhook-secret = {
file = "${self}/secrets/webhook-secret.age";
mode = "0400";
};
age.secrets.tor-onion-secret-key = lib.mkIf cfg.tor.enable {
file = "${self}/secrets/tor-onion-secret-key.age";
owner = "tor";
group = "tor";
mode = "0400";
};
age.secrets.tor-onion-public-key = lib.mkIf cfg.tor.enable {
file = "${self}/secrets/tor-onion-public-key.age";
owner = "tor";
group = "tor";
mode = "0444";
};
age.secrets.tor-onion-hostname = lib.mkIf cfg.tor.enable {
file = "${self}/secrets/tor-onion-hostname.age";
owner = "tor";
group = "tor";
mode = "0444";
};
services.tor = lib.mkIf cfg.tor.enable {
enable = true;
relay.onionServices.jetpham-website = {
map = [{ port = 80; target = { addr = "127.0.0.1"; port = 8888; }; }];
};
};
systemd.services.tor-onion-keys = lib.mkIf cfg.tor.enable {
description = "Copy Tor onion keys into place";
after = [ "agenix.service" ];
before = [ "tor.service" ];
wantedBy = [ "tor.service" ];
serviceConfig.Type = "oneshot";
script = ''
dir="/var/lib/tor/onion/jetpham-website"
mkdir -p "$dir"
cp ${config.age.secrets.tor-onion-secret-key.path} "$dir/hs_ed25519_secret_key"
cp ${config.age.secrets.tor-onion-public-key.path} "$dir/hs_ed25519_public_key"
cp ${config.age.secrets.tor-onion-hostname.path} "$dir/hostname"
chown -R tor:tor "$dir"
chmod 700 "$dir"
chmod 400 "$dir/hs_ed25519_secret_key"
chmod 444 "$dir/hs_ed25519_public_key" "$dir/hostname"
'';
};
# Q&A API systemd service
systemd.services.jetpham-qa-api = {
description = "Jet Pham Q&A API";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
DynamicUser = true;
StateDirectory = "jetpham-qa";
Environment = [ "QA_DB_PATH=/var/lib/jetpham-qa/qa.db" ];
Restart = "on-failure";
RestartSec = 5;
LoadCredential = "webhook-secret:${config.age.secrets.webhook-secret.path}";
} // lib.optionalAttrs (cfg.envFile != null) {
EnvironmentFile = cfg.envFile;
};
script = ''
export WEBHOOK_SECRET="$(cat $CREDENTIALS_DIRECTORY/webhook-secret)"
exec ${qaApi}/bin/jetpham-qa-api
'';
};
services.caddy.virtualHosts.${cfg.domain} = {
extraConfig = ''
header Cross-Origin-Opener-Policy "same-origin"
header Cross-Origin-Embedder-Policy "require-corp"
handle /api/* {
reverse_proxy 127.0.0.1:3001
}
handle {
root * ${package}
try_files {path} /index.html
file_server
}
'';
};
services.caddy.virtualHosts."http://127.0.0.1:8888" = lib.mkIf cfg.tor.enable {
extraConfig = ''
header Cross-Origin-Opener-Policy "same-origin"
header Cross-Origin-Embedder-Policy "require-corp"
handle /api/* {
reverse_proxy 127.0.0.1:3001
}
handle {
root * ${package}
try_files {path} /index.html
file_server
}
'';
};
};

133
package-lock.json generated
View file

@ -16,6 +16,8 @@
"anser": "^2.3.5",
"escape-carriage": "^1.3.1",
"eslint": "^10",
"gray-matter": "^4.0.3",
"marked": "^15.0.12",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.1",
@ -1877,6 +1879,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@ -2150,6 +2162,20 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@ -2196,6 +2222,19 @@
"node": ">=0.10.0"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2334,6 +2373,22 @@
"dev": true,
"license": "ISC"
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -2354,6 +2409,16 @@
"node": ">=0.8.19"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -2404,6 +2469,20 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
@ -2435,6 +2514,16 @@
"json-buffer": "3.0.1"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -2736,6 +2825,19 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "15.0.12",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz",
"integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@ -3094,6 +3196,20 @@
"fsevents": "~2.3.2"
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@ -3140,6 +3256,23 @@
"node": ">=0.10.0"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/tailwindcss": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz",

View file

@ -24,6 +24,8 @@
"anser": "^2.3.5",
"escape-carriage": "^1.3.1",
"eslint": "^10",
"gray-matter": "^4.0.3",
"marked": "^15.0.12",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.2.1",

View file

@ -5,4 +5,14 @@
<changefreq>monthly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://jetpham.com/projects</loc>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://jetpham.com/qa</loc>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>

10
secrets.nix Normal file
View file

@ -0,0 +1,10 @@
let
laptop = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIE40ISu3ydCqfdpb26JYD5cIN0Fu0id/FDS+xjB5zpqu";
server = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAING219cDKTDLaZefmqvOHfXvYloA/ErsCGE0pM022vlB";
in
{
"secrets/tor-onion-secret-key.age".publicKeys = [ laptop server ];
"secrets/tor-onion-public-key.age".publicKeys = [ laptop server ];
"secrets/tor-onion-hostname.age".publicKeys = [ laptop server ];
"secrets/webhook-secret.age".publicKeys = [ laptop server ];
}

Binary file not shown.

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw 2u0CVE/rQWpJNSRW/1xeWBKpShWT4ckke+Ih4j3WbRk
xeE2xTlSPEDPeC4BNkaSoOckwuOhyCQWqtXkwuhBiRo
-> ssh-ed25519 uKftJg Tt1mbTWHyXRDjvGWFBqmyrMl/PtUs45N1032luY88x8
A51wD3tiZ0lV1TSub+Pz7hZ+kndiEpnmBliP59qYzkY
--- 7X+mgLxb3uYfiYebJnAUwl/4jhGJJSweaolMttmoEIQ
3à‡’Í.Úu˼ÞZ"Àä9Ö<39>'AëâªK~.@ŽÌ‰7¾lW„÷ pý{CôÁAŠ&@¸|ÕËšV%55˜Ot¼Šr9Çîºúñ<C3BA>h»L²‡@®GõLä˜gû€

View file

@ -0,0 +1,8 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw AZTbqWYmTaudHZ8PiTZlwpf7VzwaP921guVV1iQi8WM
7CGUyEjZoAPCBX2pqNHLd2P1KLnj/Y5nnBVToWYCjWg
-> ssh-ed25519 uKftJg RfAkte/jcx+/SvZiUNH07cnBJcvl0Sjt7zSxdSCsXE0
TdMsQ2u5WXw3KAi7Tk4JOdbiFStT8F88xjDlRN8LH2Q
--- pgQnGRRVjVK02tbMgDoh3SatJFxxFLazqy5ieHu96tk
žÞxó}ûë¥T¡½<11>P^©kH ×/æôœõþݳU~ókÅ;Ö•äÕ†=•ÁºNå«\PZï|óO¨/Y íV6Ìx=„ ¹¹Â<gR+2á³C5n
©ãÃàÚ/J…k¤;5ÚNÈ—Ü#q&Øxš|­6²ƒŸ>H

View file

@ -0,0 +1,7 @@
age-encryption.org/v1
-> ssh-ed25519 Ziw7aw zDI2q+wM70YbQFw7hEQbU58XrgkWnM8fura+ovG0bTw
0Aznts9gOT8ADxFY0SIv/VT0ExEEq9wZLTnlC0j15JU
-> ssh-ed25519 uKftJg dFQxnrMwQHJ2lVBMi6P4XugljREjAHPV9bEFmLhuiBc
VWKLaM3peiELoJigapdZv0N4Z4lRZZ8eGMEFy+WtdxA
--- zJghsuTUoVH8bKynqq29kzEuNDuEjvFf5v/s98jQdaA
𡂝濮+<2B>i籤V2 *H儑齪<E58491><E9BDAA> c栛

View file

@ -0,0 +1,14 @@
export function frostedBox(content: string, extraClasses?: string): string {
return `
<div class="relative px-[2ch] py-[2ch] my-[2ch] w-full max-w-[66.666667%] min-w-fit ${extraClasses ?? ""}">
<div
class="pointer-events-none absolute inset-0 h-[200%]"
style="background-color: rgba(0, 0, 0, 0.75); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%); -webkit-mask-image: linear-gradient(to bottom, black 0% 50%, transparent 50% 100%);"
aria-hidden="true"
></div>
<div class="absolute inset-0 border-2 border-white" aria-hidden="true"></div>
<div class="relative z-10">
${content}
</div>
</div>`;
}

View file

@ -0,0 +1,10 @@
---
title: Conway's Game of Life
description: WebAssembly implementation of Conway's Game of Life, running as the background of this website.
---
The background animation on this site is a WebAssembly implementation of
Conway's Game of Life, written in Rust and compiled to WASM.
It runs directly in your browser using the HTML5 Canvas API, simulating
cellular automata in real-time.

20
src/global.d.ts vendored
View file

@ -14,3 +14,23 @@ declare module "*.utf8ans?raw" {
const content: string;
export default content;
}
declare module "virtual:projects" {
interface Project {
slug: string;
title: string;
description: string;
html: string;
}
const projects: Project[];
export default projects;
}
declare module "gray-matter" {
interface GrayMatterResult {
data: Record<string, string>;
content: string;
}
function matter(input: string): GrayMatterResult;
export = matter;
}

25
src/lib/api.ts Normal file
View file

@ -0,0 +1,25 @@
export interface Question {
id: number;
question: string;
answer: string;
created_at: string;
answered_at: string;
}
export async function getQuestions(): Promise<Question[]> {
const res = await fetch("/api/questions");
if (!res.ok) throw new Error("Failed to fetch questions");
return res.json() as Promise<Question[]>;
}
export async function submitQuestion(question: string): Promise<void> {
const res = await fetch("/api/questions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question }),
});
if (!res.ok) {
if (res.status === 429) throw new Error("Too many questions. Please try again later.");
throw new Error("Failed to submit question");
}
}

View file

@ -1,8 +1,13 @@
import "~/styles/globals.css";
import Jet from "~/assets/Jet.txt?ansi";
import init, { start } from "cgol";
import { route, initRouter } from "~/router";
import { homePage } from "~/pages/home";
import { qaPage } from "~/pages/qa";
import { notFoundPage } from "~/pages/not-found";
document.getElementById("ansi-art")!.innerHTML = Jet;
route("/", homePage);
route("/qa", qaPage);
route("*", notFoundPage);
try {
await init();
@ -10,3 +15,5 @@ try {
} catch (e) {
console.error("WASM init failed:", e);
}
initRouter();

40
src/pages/home.ts Normal file
View file

@ -0,0 +1,40 @@
import Jet from "~/assets/Jet.txt?ansi";
import { frostedBox } from "~/components/frosted-box";
export function homePage(outlet: HTMLElement) {
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<div class="flex flex-col items-center justify-center gap-[2ch] md:flex-row">
<div class="order-1 flex flex-col items-center md:order-2">
<h1 class="sr-only">Jet Pham</h1>
<div aria-hidden="true">${Jet}</div>
<p class="mt-[2ch]">Software Extremist</p>
</div>
<div class="order-2 shrink-0 md:order-1">
<img
src="/jet.svg"
alt="A picture of Jet wearing a beanie in purple and blue lighting"
width="250"
height="250"
class="aspect-square w-full max-w-[250px] object-cover md:h-[263px] md:w-[175px] md:max-w-none"
style="background-color: #a80055; color: transparent"
/>
</div>
</div>
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Contact</legend>
<a href="mailto:jet@extremist.software">jet@extremist.software</a>
</fieldset>
<fieldset class="mt-[2ch] border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Links</legend>
<ol>
<li><a href="https://git.extremist.software" class="inline-flex items-center">Forgejo</a></li>
<li><a href="https://github.com/jetpham" class="inline-flex items-center">GitHub</a></li>
<li><a href="https://x.com/exmistsoftware" class="inline-flex items-center">X</a></li>
<li><a href="https://bsky.app/profile/extremist.software" class="inline-flex items-center">Bluesky</a></li>
</ol>
</fieldset>
`)}
</div>`;
}

12
src/pages/not-found.ts Normal file
View file

@ -0,0 +1,12 @@
import { frostedBox } from "~/components/frosted-box";
export function notFoundPage(outlet: HTMLElement) {
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<h1 style="color: var(--light-red);">404</h1>
<p class="mt-[1ch]">Page not found.</p>
<p class="mt-[1ch]"><a href="/">[BACK TO HOME]</a></p>
`)}
</div>`;
}

24
src/pages/project.ts Normal file
View file

@ -0,0 +1,24 @@
import projects from "virtual:projects";
import { frostedBox } from "~/components/frosted-box";
export function projectPage(outlet: HTMLElement, params: Record<string, string>) {
const project = projects.find((p) => p.slug === params.slug);
if (!project) {
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<h1 style="color: var(--light-red);">Project not found</h1>
<p class="mt-[1ch]"><a href="/projects">[BACK TO PROJECTS]</a></p>
`)}
</div>`;
return;
}
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<h1 style="color: var(--yellow);">${project.title}</h1>
<div class="project-content mt-[2ch]">${project.html}</div>
`)}
</div>`;
}

22
src/pages/projects.ts Normal file
View file

@ -0,0 +1,22 @@
import projects from "virtual:projects";
import { frostedBox } from "~/components/frosted-box";
export function projectsPage(outlet: HTMLElement) {
const list = projects
.map(
(p) => `
<li class="mb-[1ch]">
<a href="/projects/${p.slug}" style="color: var(--light-cyan);">${p.title}</a>
<p style="color: var(--light-gray);">${p.description}</p>
</li>`,
)
.join("");
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<h1 style="color: var(--yellow);">Projects</h1>
<ul class="mt-[2ch]">${list}</ul>
`)}
</div>`;
}

87
src/pages/qa.ts Normal file
View file

@ -0,0 +1,87 @@
import { getQuestions, submitQuestion } from "~/lib/api";
import { frostedBox } from "~/components/frosted-box";
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
export async function qaPage(outlet: HTMLElement) {
outlet.innerHTML = `
<div class="flex flex-col items-center justify-start px-4">
${frostedBox(`
<form id="qa-form">
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-[1ch]">
<legend class="-mx-[0.5ch] px-[0.5ch] text-white">Ask a Question</legend>
<textarea id="qa-input" maxlength="200" rows="3"
class="qa-textarea"
placeholder="Type your question..."></textarea>
<div class="flex justify-between mt-[1ch]">
<span id="char-count" style="color: var(--dark-gray);">0/200</span>
<button type="submit" class="qa-button">[SUBMIT]</button>
</div>
<p id="qa-status" class="mt-[0.5ch]" aria-live="polite"></p>
</fieldset>
</form>
<div id="qa-list" class="mt-[2ch]">Loading...</div>
`)}
</div>`;
const form = document.getElementById("qa-form") as HTMLFormElement;
const input = document.getElementById("qa-input") as HTMLTextAreaElement;
const charCount = document.getElementById("char-count")!;
const status = document.getElementById("qa-status")!;
const list = document.getElementById("qa-list")!;
input.addEventListener("input", () => {
charCount.textContent = `${input.value.length}/200`;
});
form.addEventListener("submit", (e) => {
e.preventDefault();
const question = input.value.trim();
if (!question) return;
status.textContent = "Submitting...";
status.style.color = "var(--light-gray)";
submitQuestion(question)
.then(() => {
input.value = "";
charCount.textContent = "0/200";
status.textContent = "Question submitted! It will appear here once answered.";
status.style.color = "var(--light-green)";
})
.catch((err: unknown) => {
status.textContent = err instanceof Error ? err.message : "Failed to submit question.";
status.style.color = "var(--light-red)";
});
});
try {
const questions = await getQuestions();
if (questions.length === 0) {
list.textContent = "No questions answered yet.";
list.style.color = "var(--dark-gray)";
} else {
list.innerHTML = questions
.map(
(q) => `
<fieldset class="border-2 border-white px-[calc(1.5ch-0.5px)] pb-[1ch] pt-0 mb-[2ch]">
<legend class="-mx-[0.5ch] px-[0.5ch]" style="color: var(--dark-gray);">#${String(q.id)}</legend>
<p style="color: var(--light-cyan);">${escapeHtml(q.question)}</p>
<p class="mt-[1ch]" style="color: var(--light-green);">${escapeHtml(q.answer)}</p>
<p class="mt-[0.5ch]" style="color: var(--dark-gray);">
Asked ${q.created_at} · Answered ${q.answered_at}
</p>
</fieldset>`,
)
.join("");
}
} catch {
list.textContent = "Failed to load questions.";
list.style.color = "var(--light-red)";
list.style.textAlign = "center";
}
}

68
src/router.ts Normal file
View file

@ -0,0 +1,68 @@
type PageHandler = (
outlet: HTMLElement,
params: Record<string, string>,
) => void | Promise<void>;
interface Route {
pattern: RegExp;
keys: string[];
handler: PageHandler;
}
const routes: Route[] = [];
let notFoundHandler: PageHandler | null = null;
export function route(path: string, handler: PageHandler) {
if (path === "*") {
notFoundHandler = handler;
return;
}
const keys: string[] = [];
const pattern = path.replace(/:(\w+)/g, (_, key: string) => {
keys.push(key);
return "([^/]+)";
});
routes.push({ pattern: new RegExp(`^${pattern}$`), keys, handler });
}
export function navigate(path: string) {
history.pushState(null, "", path);
void render();
}
async function render() {
const path = location.pathname;
const outlet = document.getElementById("outlet")!;
for (const r of routes) {
const match = path.match(r.pattern);
if (match) {
const params: Record<string, string> = {};
r.keys.forEach((key, i) => {
params[key] = match[i + 1]!;
});
outlet.innerHTML = "";
await r.handler(outlet, params);
return;
}
}
outlet.innerHTML = "";
if (notFoundHandler) {
await notFoundHandler(outlet, {});
}
}
export function initRouter() {
window.addEventListener("popstate", () => void render());
document.addEventListener("click", (e) => {
const anchor = (e.target as HTMLElement).closest("a");
if (anchor?.origin === location.origin && !anchor.hasAttribute("download")) {
e.preventDefault();
navigate(anchor.pathname);
}
});
void render();
}

View file

@ -80,7 +80,73 @@
}
a:hover {
color: var(--blue);
background-color: var(--light-blue);
color: var(--black);
}
/* Form inputs */
.qa-textarea {
width: 100%;
background-color: var(--black);
border: 2px solid var(--white);
color: var(--light-gray);
padding: 1ch;
resize: none;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
.qa-button {
border: none;
padding: 0.25ch 1ch;
color: var(--yellow);
background: transparent;
font-family: inherit;
font-size: inherit;
cursor: pointer;
}
.qa-button:hover {
background-color: var(--yellow);
color: var(--black);
}
/* Project markdown content */
.project-content h2 {
color: var(--light-cyan);
margin-top: 2ch;
margin-bottom: 1ch;
}
.project-content h3 {
color: var(--light-green);
margin-top: 1.5ch;
margin-bottom: 0.5ch;
}
.project-content p {
margin-bottom: 1ch;
}
.project-content ul,
.project-content ol {
margin-left: 2ch;
margin-bottom: 1ch;
}
.project-content code {
color: var(--yellow);
}
.project-content pre {
border: 2px solid var(--dark-gray);
padding: 1ch;
overflow-x: auto;
margin-bottom: 1ch;
}
.project-content a {
color: var(--light-blue);
}

View file

@ -17,7 +17,8 @@
"include": [
"src",
"vite.config.ts",
"vite-plugin-ansi.ts"
"vite-plugin-ansi.ts",
"vite-plugin-markdown.ts"
],
"exclude": [
"node_modules",

32
vite-plugin-markdown.ts Normal file
View file

@ -0,0 +1,32 @@
import fs from "node:fs";
import path from "node:path";
import matter from "gray-matter";
import { marked } from "marked";
import type { Plugin } from "vite";
export default function markdownPlugin(): Plugin {
const virtualModuleId = "virtual:projects";
const resolvedId = "\0" + virtualModuleId;
const projectsDir = path.resolve("src/content/projects");
return {
name: "vite-plugin-markdown",
resolveId(id) {
if (id === virtualModuleId) return resolvedId;
},
load(id) {
if (id !== resolvedId) return;
const files = fs.readdirSync(projectsDir).filter((f) => f.endsWith(".md"));
const projects = files.map((file) => {
const raw = fs.readFileSync(path.join(projectsDir, file), "utf-8");
const { data, content } = matter(raw);
const html = marked(content);
const slug = file.replace(".md", "");
return { slug, ...data, html };
});
return `export default ${JSON.stringify(projects)};`;
},
};
}

View file

@ -4,10 +4,12 @@ import wasm from "vite-plugin-wasm";
import topLevelAwait from "vite-plugin-top-level-await";
import { viteSingleFile } from "vite-plugin-singlefile";
import ansi from "./vite-plugin-ansi";
import markdown from "./vite-plugin-markdown";
export default defineConfig({
plugins: [
ansi(),
markdown(),
tailwindcss(),
wasm(),
topLevelAwait(),