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