feat: Basic functionality
This commit is contained in:
commit
94edcf3199
3607
Cargo.lock
generated
Normal file
3607
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal file
@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "website"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.8.7"
|
||||
flate2 = "1.1.5"
|
||||
itertools = "0.14.0"
|
||||
tar = "0.4.44"
|
||||
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 = { version = "0.3.22", features = ["env-filter"] }
|
||||
typst-html = { git = "https://github.com/mkorje/typst.git", branch = "mathml" }
|
||||
typst-kit = { git = "https://github.com/mkorje/typst.git", branch = "mathml", features = [
|
||||
# "embed-fonts",
|
||||
] }
|
||||
typst = { git = "https://github.com/mkorje/typst.git", branch = "mathml" }
|
||||
ureq = "3.1.4"
|
||||
time = "0.3.47"
|
||||
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
|
||||
}
|
||||
39
flake.nix
Normal file
39
flake.nix
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
...
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
cargo
|
||||
rustc
|
||||
rustfmt
|
||||
clippy
|
||||
bacon
|
||||
typst
|
||||
openssl
|
||||
pkg-config
|
||||
];
|
||||
|
||||
RUST_SRC_PATH = pkgs.rustPlatform.rustLibSrc;
|
||||
TYPST_FEATURES = "html";
|
||||
TYPST_PACKAGE_PATH = "/home/Jan/Code/website/content";
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
125
src/main.rs
Normal file
125
src/main.rs
Normal file
@ -0,0 +1,125 @@
|
||||
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 itertools::Itertools;
|
||||
use tower_http::{
|
||||
compression::CompressionLayer,
|
||||
services::{ServeDir, ServeFile},
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use tracing::Level;
|
||||
use tracing_subscriber::FmtSubscriber;
|
||||
|
||||
use crate::typst::TypstContext;
|
||||
|
||||
mod typst;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
typst: Arc<TypstContext>,
|
||||
config: AppConfig,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppConfig {
|
||||
port: u16,
|
||||
content_root: PathBuf,
|
||||
common_root: PathBuf,
|
||||
packages_root: PathBuf,
|
||||
static_root: PathBuf,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
let port = 3000;
|
||||
let content_root = PathBuf::from("content");
|
||||
let common_root = PathBuf::from("content/common");
|
||||
let packages_root = PathBuf::from("packages");
|
||||
let static_root = PathBuf::from("content/assets");
|
||||
Self {
|
||||
port,
|
||||
content_root,
|
||||
common_root,
|
||||
packages_root,
|
||||
static_root,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
let config = AppConfig::default();
|
||||
let typst = Arc::new(TypstContext::new(config.clone()));
|
||||
Self { typst, config }
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let state = AppState::new();
|
||||
|
||||
let subscriber = FmtSubscriber::builder()
|
||||
.with_max_level(Level::DEBUG)
|
||||
.finish();
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("Setting default logging subscriber failed.");
|
||||
|
||||
let mut favicon = state.config.static_root.clone();
|
||||
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.static_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);
|
||||
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,
|
||||
Html(
|
||||
#[allow(unstable_name_collisions)]
|
||||
err.into_iter()
|
||||
.map(|err| format!("{:?}", err))
|
||||
.intersperse(String::from("<br/>"))
|
||||
.collect::<String>(),
|
||||
),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
187
src/typst/mod.rs
Normal file
187
src/typst/mod.rs
Normal file
@ -0,0 +1,187 @@
|
||||
use std::{
|
||||
ffi::OsStr,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use time::{OffsetDateTime, PrimitiveDateTime, UtcDateTime, UtcOffset};
|
||||
use tracing::{error, info};
|
||||
use typst::{
|
||||
Features, 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.common_root))
|
||||
}
|
||||
|
||||
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