feat: Basic functionality
This commit is contained in:
commit
abfdea2e78
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
3715
Cargo.lock
generated
Normal file
3715
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
Normal file
20
Cargo.toml
Normal 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
61
flake.lock
generated
Normal 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
54
flake.nix
Normal 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
145
src/main.rs
Normal 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
182
src/typst/mod.rs
Normal 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()
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user