feat: Basic functionality

This commit is contained in:
Jan-Bulthuis 2026-04-02 14:46:12 +02:00
commit abfdea2e78
7 changed files with 4178 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/target

3715
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

20
Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "karnaugh"
version = "0.1.0"
edition = "2024"
[dependencies]
axum = "0.8.7"
flate2 = "1.1.5"
itertools = "0.14.0"
tar = "0.4.44"
time = "0.3.47"
tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] }
tower-http = { version = "0.6.8", features = ["compression-br", "fs", "trace"] }
tracing = "0.1.43"
tracing-subscriber = "0.3.22"
typst-html = { git = "https://github.com/mkorje/typst.git", branch = "mathml" }
typst-kit = { git = "https://github.com/mkorje/typst.git", branch = "mathml" }
typst = { git = "https://github.com/mkorje/typst.git", branch = "mathml" }
ureq = "3.1.4"
clap = { version = "4.6.0", features = ["derive"] }

61
flake.lock generated Normal file
View File

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1773821835,
"narHash": "sha256-TJ3lSQtW0E2JrznGVm8hOQGVpXjJyXY2guAxku2O9A4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "b40629efe5d6ec48dd1efba650c797ddbd39ace0",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"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",
"version": 7
}

54
flake.nix Normal file
View File

@ -0,0 +1,54 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs {
inherit system;
};
in
rec {
packages.karnaugh = pkgs.rustPlatform.buildRustPackage (final: {
pname = "karnaugh";
version = "0.1.0";
src = self;
cargoHash = "sha256-4jmvuQiQz5TCkY//L2qyx0AiDTxHu8EocFfysgaTaHU=";
});
packages.container = pkgs.dockerTools.buildImage {
name = "karnaugh";
tag = packages.karnaugh.version;
copyToRoot = pkgs.buildEnv {
name = "karnaugh-root";
paths = [packages.karnaugh];
pathsToLink = ["/bin"];
};
config = {
Cmd = ["/bin/karnaugh"];
};
};
packages.default = packages.karnaugh;
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
cargo
rustc
rustfmt
clippy
];
};
}
);
}

145
src/main.rs Normal file
View File

@ -0,0 +1,145 @@
use std::{
net::{Ipv4Addr, SocketAddrV4},
path::PathBuf,
sync::Arc,
};
use ::typst::syntax::VirtualPath;
use axum::{
Router,
extract::{Path, State},
http::StatusCode,
response::{Html, IntoResponse, Response},
routing::get,
};
use clap::Parser;
use itertools::Itertools;
use tower_http::{
compression::CompressionLayer,
services::{ServeDir, ServeFile},
trace::TraceLayer,
};
use tracing::{Level, info};
use tracing_subscriber::FmtSubscriber;
use crate::typst::TypstContext;
mod typst;
#[derive(Clone)]
struct AppState {
typst: Arc<TypstContext>,
config: AppConfig,
}
/// A Typst-based (static) site generator.
#[derive(Clone, Parser)]
#[command(version, about)]
struct AppConfig {
/// The port to expose the site on.
#[arg(short = 'p', long, default_value_t = 3000)]
port: u16,
/// The directory where typst files and related content are located.
#[arg(short = 'c', long, value_name = "DIR", default_value = "./content")]
content_root: PathBuf,
/// The directory relative to content root where local packages are installed.
#[arg(short = 's', long, value_name = "DIR", default_value = "common")]
common_root: PathBuf,
/// The directory relative to content root where assets are stored.
/// Should be inside the content root to ensure that typst ran externally from
/// karnaugh can also find them, this is not required however.
#[arg(short = 'a', long, value_name = "DIR", default_value = "assets")]
assets_root: PathBuf,
/// The directory where packages required to render pages will be downloaded.
#[arg(short = 'd', long, value_name = "DIR", default_value = "/tmp/karnaugh")]
packages_root: PathBuf,
/// The log level to use
#[arg(short = 'l', long, value_name = "LEVEL", default_value_t = Level::INFO)]
log_level: Level,
}
impl AppConfig {
fn get_full_common_path(&self) -> PathBuf {
let mut root = self.content_root.clone();
root.push(&self.common_root);
root
}
fn get_full_assets_path(&self) -> PathBuf {
let mut root = self.content_root.clone();
root.push(&self.assets_root);
root
}
}
impl AppState {
fn new() -> Self {
let config = AppConfig::parse();
let typst = Arc::new(TypstContext::new(config.clone()));
Self { typst, config }
}
}
#[tokio::main]
async fn main() {
let state = AppState::new();
FmtSubscriber::builder()
.with_max_level(state.config.log_level)
.init();
// TODO: Replace with config option
let mut favicon = state.config.get_full_assets_path();
favicon.push(PathBuf::from("favicon.svg"));
let app = Router::new()
.route("/", get(root_handler))
.route("/{*path}", get(typst_handler))
.route_service("/favicon.ico", ServeFile::new(favicon))
.nest_service("/assets", ServeDir::new(&state.config.assets_root))
.with_state(state.clone())
.layer(TraceLayer::new_for_http())
.layer(CompressionLayer::new());
let socket = SocketAddrV4::new(Ipv4Addr::new(0, 0, 0, 0), state.config.port);
info!("Serving Karnaugh on socket {}", socket);
let listener = tokio::net::TcpListener::bind(socket).await.unwrap();
axum::serve(listener, app).await.unwrap();
}
async fn root_handler(State(state): State<AppState>) -> impl IntoResponse {
typst_handler(State(state), Path("index".into())).await
}
async fn typst_handler(State(state): State<AppState>, Path(path): Path<String>) -> Response {
if path.starts_with('/') || path.contains("..") || path.contains('\\') {
return StatusCode::BAD_REQUEST.into_response();
}
let path = match VirtualPath::new(path) {
Ok(path) => path,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
let path = path.with_extension("typ");
match state.typst.compile_document(path) {
Ok(content) => Html(content).into_response(),
Err(err) => (
StatusCode::INTERNAL_SERVER_ERROR,
// TODO: Fancy error formatting
Html(
#[allow(unstable_name_collisions)]
err.into_iter()
.map(|err| format!("{:?}", err))
.intersperse(String::from("<br/>"))
.collect::<String>(),
),
)
.into_response(),
}
}

182
src/typst/mod.rs Normal file
View File

@ -0,0 +1,182 @@
use std::path::PathBuf;
use time::{OffsetDateTime, PrimitiveDateTime, UtcDateTime, UtcOffset};
use tracing::info;
use typst::{
Library, LibraryExt, World,
diag::{FileError, FileResult, PackageError, PackageResult, SourceDiagnostic},
foundations::{Bytes, Datetime, Duration},
syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot, package::PackageSpec},
text::{Font, FontBook},
utils::LazyHash,
};
use typst_kit::fonts::FontStore;
use crate::AppConfig;
pub struct TypstContext {
config: AppConfig,
library: LazyHash<Library>,
fonts: FontStore,
}
impl TypstContext {
pub fn new(config: AppConfig) -> Self {
let library = Library::builder()
.with_features([typst::Feature::Html].into_iter().collect())
.build();
let library = LazyHash::new(library);
let fonts = FontStore::new();
Self {
config,
library,
fonts,
}
}
// TODO: Better return error
pub fn compile_document(&self, path: VirtualPath) -> Result<String, Vec<SourceDiagnostic>> {
info!("Compiling {:?}", &path);
let world = DocumentWorld::new(path, self);
let compiled = typst::compile(&world);
// TODO: Log warnings
// TODO: Log errors
compiled
.output
.and_then(|doc| typst_html::html(&doc))
.map_err(|err| err.into_iter().collect())
}
}
struct DocumentWorld<'a> {
main: FileId,
now: UtcDateTime,
context: &'a TypstContext,
}
impl<'a> World for DocumentWorld<'a> {
fn library(&self) -> &LazyHash<Library> {
&self.context.library
}
fn book(&self) -> &LazyHash<FontBook> {
self.context.fonts.book()
}
fn main(&self) -> FileId {
self.main
}
fn source(&self, id: FileId) -> FileResult<Source> {
self.resolve_file(id).and_then(|path| {
let text = std::fs::read_to_string(&path).map_err(|e| FileError::from_io(e, &path))?;
Ok(Source::new(id, text))
})
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
self.resolve_file(id).and_then(|path| {
let bytes = std::fs::read(&path).map_err(|e| FileError::from_io(e, &path))?;
Ok(Bytes::new(bytes))
})
}
fn font(&self, index: usize) -> Option<Font> {
self.context.fonts.font(index)
}
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
let offset = offset
.map(|v| v.seconds() as i32)
.and_then(|v| UtcOffset::from_whole_seconds(v).ok())
.unwrap_or(UtcOffset::UTC);
let datetime = OffsetDateTime::from(self.now).replace_offset(offset);
let datetime = PrimitiveDateTime::new(datetime.date(), datetime.time());
Some(Datetime::Datetime(datetime))
}
}
impl<'a> DocumentWorld<'a> {
fn new(path: VirtualPath, context: &'a TypstContext) -> Self {
let main = FileId::new(RootedPath::new(VirtualRoot::Project, path));
let now = UtcDateTime::now();
Self { main, now, context }
}
fn resolve_file(&self, id: FileId) -> FileResult<PathBuf> {
let path = id.vpath();
match id.root() {
VirtualRoot::Project => self.resolve_project_file(path),
VirtualRoot::Package(package_spec) => self.resolve_package_file(path, package_spec),
}
}
fn resolve_project_file(&self, path: &VirtualPath) -> FileResult<PathBuf> {
Ok(path.realize(&self.context.config.content_root))
}
fn resolve_package_file(&self, path: &VirtualPath, spec: &PackageSpec) -> FileResult<PathBuf> {
match spec.namespace.as_str() {
"common" => self.resolve_common_file(path, spec),
"preview" => self.resolve_preview_file(path, spec),
_ => unimplemented!(),
}
}
fn resolve_common_file(&self, path: &VirtualPath, spec: &PackageSpec) -> FileResult<PathBuf> {
let path = get_package_path(path, spec);
Ok(path.realize(&self.context.config.get_full_common_path()))
}
fn resolve_preview_file(&self, path: &VirtualPath, spec: &PackageSpec) -> FileResult<PathBuf> {
let package_path = self.get_package(spec)?;
Ok(path.realize(&package_path))
}
fn get_package(&self, spec: &PackageSpec) -> PackageResult<PathBuf> {
let path = get_package_path(&VirtualPath::new("").unwrap(), spec);
let path = path.realize(&self.context.config.packages_root);
if path.exists() {
return Ok(path);
}
let url = format!(
"https://packages.typst.org/preview/{}-{}.tar.gz",
spec.name, spec.version
);
info!("Downloading package from {}", url);
let response = ureq::get(&url)
.call()
.map_err(|err| err.to_string())
.and_then(|res| {
if res.status().is_success() {
Ok(res)
} else {
Err(format!("Received status code {}", res.status()))
}
})
.map_err(|err| PackageError::NetworkFailed(Some(err.into())))?;
let archive = response.into_body().into_reader();
let archive = flate2::read::GzDecoder::new(archive);
let mut archive = tar::Archive::new(archive);
archive.unpack(&path).map_err(|err| {
// let _ = std::fs::remove_dir_all(path.clone());
PackageError::MalformedArchive(Some(err.to_string().into()))
})?;
Ok(path)
}
}
fn get_package_path(file_path: &VirtualPath, spec: &PackageSpec) -> VirtualPath {
let package_path = VirtualPath::new(format!("{}/{}", spec.name, spec.version)).unwrap();
package_path.join(file_path.get_without_slash()).unwrap()
}