diff --git a/Cargo.toml b/Cargo.toml index 3233546..eba9a8f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ documentation = "https://docs.rs/northstar" [features] default = ["serve_dir"] -serve_dir = ["mime_guess", "tokio/fs"] +serve_dir = ["mime_guess", "tokio/fs", "gemtext"] [dependencies] anyhow = "1.0.33" @@ -25,6 +25,7 @@ log = "0.4.11" webpki = "0.21.0" lazy_static = "1.4.0" mime_guess = { version = "2.0.3", optional = true } +gemtext = { git = "https://tulpa.dev/alch_emii/maj-prs.git", branch = "local-main", optional = true } [dev-dependencies] env_logger = "0.8.1" @@ -34,3 +35,7 @@ tokio = { version = "0.3.1", features = ["macros", "rt-multi-thread", "sync"] } [[example]] name = "serve_dir" required-features = ["serve_dir"] + +[[example]] +name = "document" +required-features = ["gemtext"] diff --git a/examples/document.rs b/examples/document.rs index e2e685b..c363207 100644 --- a/examples/document.rs +++ b/examples/document.rs @@ -2,8 +2,7 @@ use anyhow::*; use futures_core::future::BoxFuture; use futures_util::FutureExt; use log::LevelFilter; -use northstar::{Server, Request, Response, GEMINI_PORT, Document}; -use northstar::document::HeadingLevel::*; +use northstar::{Server, Request, Response, GEMINI_PORT, Gemtext}; #[tokio::main] async fn main() -> Result<()> { @@ -18,27 +17,27 @@ async fn main() -> Result<()> { fn handle_request(_request: Request) -> BoxFuture<'static, Result> { async move { - let response = Document::new() - .add_preformatted(include_str!("northstar_logo.txt")) - .add_blank_line() - .add_link("https://docs.rs/northstar", "Documentation") - .add_link("https://github.com/panicbit/northstar", "GitHub") - .add_blank_line() - .add_heading(H1, "Usage") - .add_blank_line() - .add_text("Add the latest version of northstar to your `Cargo.toml`.") - .add_blank_line() - .add_heading(H2, "Manually") - .add_blank_line() - .add_preformatted_with_alt("toml", r#"northstar = "0.3.0" # check crates.io for the latest version"#) - .add_blank_line() - .add_heading(H2, "Automatically") - .add_blank_line() - .add_preformatted_with_alt("sh", "cargo add northstar") - .add_blank_line() - .add_heading(H1, "Generating a key & certificate") - .add_blank_line() - .add_preformatted_with_alt("sh", concat!( + let response = Gemtext::new() + .preformatted("Northstar", include_str!("northstar_logo.txt")) + .blank_line() + .link("https://docs.rs/northstar", Some("Documentation")) + .link("https://github.com/panicbit/northstar", Some("GitHub")) + .blank_line() + .heading(1, "Usage") + .blank_line() + .text("Add the latest version of northstar to your `Cargo.toml`.") + .blank_line() + .heading(2, "Manually") + .blank_line() + .preformatted("toml", r#"northstar = "0.3.0" # check crates.io for the latest version"#) + .blank_line() + .heading(2, "Automatically") + .blank_line() + .preformatted("sh", "cargo add northstar") + .blank_line() + .heading(1, "Generating a key & certificate") + .blank_line() + .preformatted("sh", concat!( "mkdir cert && cd cert\n", "openssl req -x509 -nodes -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365", )) diff --git a/src/lib.rs b/src/lib.rs index a014a8a..b840f72 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,6 +31,9 @@ pub use mime; pub use uriparse as uri; pub use types::*; +#[cfg(feature = "gemtext")] +pub use gemtext::Builder as Gemtext; + pub const REQUEST_URI_MAX_LEN: usize = 1024; pub const GEMINI_PORT: u16 = 1965; diff --git a/src/types.rs b/src/types.rs index b376427..1667bea 100644 --- a/src/types.rs +++ b/src/types.rs @@ -19,6 +19,3 @@ pub use response::Response; mod body; pub use body::Body; - -pub mod document; -pub use document::Document; diff --git a/src/types/body.rs b/src/types/body.rs index a7481c3..d4a3abc 100644 --- a/src/types/body.rs +++ b/src/types/body.rs @@ -2,18 +2,29 @@ use tokio::io::AsyncRead; #[cfg(feature="serve_dir")] use tokio::fs::File; -use std::borrow::Borrow; - -use crate::types::Document; +#[cfg(feature = "gemtext")] +use crate::Gemtext; pub enum Body { Bytes(Vec), Reader(Box), } -impl> From for Body { - fn from(document: D) -> Self { - Self::from(document.borrow().to_string()) +#[cfg(feature = "gemtext")] +#[allow(clippy::fallible_impl_from)] // It's really not fallible but thanks +impl From> for Body { + fn from(document: Vec) -> Self { + let size: usize = document.iter().map(gemtext::Node::estimate_len).sum(); + let mut bytes = Vec::with_capacity(size + document.len()); + gemtext::render(document, &mut bytes).unwrap(); // Safe: we're only writing to a buffer + Self::Bytes(bytes) + } +} + +#[cfg(feature = "gemtext")] +impl From for Body { + fn from(document: Gemtext) -> Self { + document.build().into() } } diff --git a/src/types/document.rs b/src/types/document.rs deleted file mode 100644 index 06d12bf..0000000 --- a/src/types/document.rs +++ /dev/null @@ -1,558 +0,0 @@ -//! Provides types for creating Gemini Documents. -//! -//! The module is centered around the `Document` type, -//! which provides all the necessary methods for programatically -//! creation of Gemini documents. -//! -//! # Examples -//! -//! ``` -//! use northstar::document::HeadingLevel::*; -//! -//! let mut document = northstar::Document::new(); -//! -//! document.add_heading(H1, "Heading 1"); -//! document.add_heading(H2, "Heading 2"); -//! document.add_heading(H3, "Heading 3"); -//! document.add_blank_line(); -//! document.add_text("text"); -//! document.add_link("gemini://gemini.circumlunar.space", "Project Gemini"); -//! document.add_unordered_list_item("list item"); -//! document.add_quote("quote"); -//! document.add_preformatted("preformatted"); -//! -//! assert_eq!(document.to_string(), "\ -//! ## Heading 1\n\ -//! ### Heading 2\n\ -//! #### Heading 3\n\ -//! \n\ -//! text\n\ -//! => gemini://gemini.circumlunar.space/ Project Gemini\n\ -//! * list item\n\ -//! > quote\n\ -//! ```\n\ -//! preformatted\n\ -//! ```\n\ -//! "); -//! ``` -#![warn(missing_docs)] -use std::convert::TryInto; -use std::fmt; - -use crate::types::URIReference; -use crate::util::Cowy; - -#[derive(Default)] -/// Represents a Gemini document. -/// -/// Provides convenient methods for programatically -/// creation of Gemini documents. -pub struct Document { - items: Vec, -} - -impl Document { - /// Creates an empty Gemini `Document`. - /// - /// # Examples - /// - /// ``` - /// let document = northstar::Document::new(); - /// - /// assert_eq!(document.to_string(), ""); - /// ``` - pub fn new() -> Self { - Self::default() - } - - /// Adds an `item` to the document. - /// - /// An `item` usually corresponds to a single line, - /// except in the case of preformatted text. - /// - /// # Examples - /// - /// ```compile_fail - /// use northstar::document::{Document, Item, Text}; - /// - /// let mut document = Document::new(); - /// let text = Text::new_lossy("foo"); - /// let item = Item::Text(text); - /// - /// document.add_item(item); - /// - /// assert_eq!(document.to_string(), "foo\n"); - /// ``` - fn add_item(&mut self, item: Item) -> &mut Self { - self.items.push(item); - self - } - - /// Adds multiple `items` to the document. - /// - /// This is a convenience wrapper around `add_item`. - /// - /// # Examples - /// - /// ```compile_fail - /// use northstar::document::{Document, Item, Text}; - /// - /// let mut document = Document::new(); - /// let items = vec!["foo", "bar", "baz"] - /// .into_iter() - /// .map(Text::new_lossy) - /// .map(Item::Text); - /// - /// document.add_items(items); - /// - /// assert_eq!(document.to_string(), "foo\nbar\nbaz\n"); - /// ``` - fn add_items(&mut self, items: I) -> &mut Self - where - I: IntoIterator, - { - self.items.extend(items); - self - } - - /// Adds a blank line to the document. - /// - /// # Examples - /// - /// ``` - /// let mut document = northstar::Document::new(); - /// - /// document.add_blank_line(); - /// - /// assert_eq!(document.to_string(), "\n"); - /// ``` - pub fn add_blank_line(&mut self) -> &mut Self { - self.add_item(Item::Text(Text::blank())) - } - - /// Adds plain text to the document. - /// - /// This function allows adding multiple lines at once. - /// - /// It inserts a whitespace at the beginning of a line - /// if it starts with a character sequence that - /// would make it a non-plain text line (e.g. link, heading etc). - /// - /// # Examples - /// - /// ``` - /// let mut document = northstar::Document::new(); - /// - /// document.add_text("hello\n* world!"); - /// - /// assert_eq!(document.to_string(), "hello\n * world!\n"); - /// ``` - pub fn add_text(&mut self, text: impl AsRef) -> &mut Self { - let text = text - .as_ref() - .lines() - .map(Text::new_lossy) - .map(Item::Text); - - self.add_items(text); - - self - } - - /// Adds a link to the document. - /// - /// `uri`s that fail to parse are substituted with `.`. - /// - /// Consecutive newlines in `label` will be replaced - /// with a single whitespace. - /// - /// # Examples - /// - /// ``` - /// let mut document = northstar::Document::new(); - /// - /// document.add_link("https://wikipedia.org", "Wiki\n\nWiki"); - /// - /// assert_eq!(document.to_string(), "=> https://wikipedia.org/ Wiki Wiki\n"); - /// ``` - pub fn add_link<'a, U>(&mut self, uri: U, label: impl Cowy) -> &mut Self - where - U: TryInto>, - { - let uri = uri - .try_into() - .map(URIReference::into_owned) - .or_else(|_| ".".try_into()).expect("Northstar BUG"); - let label = LinkLabel::from_lossy(label); - let link = Link { uri: Box::new(uri), label: Some(label) }; - let link = Item::Link(link); - - self.add_item(link); - - self - } - - /// Adds a link to the document, but without a label. - /// - /// See `add_link` for details. - /// - /// # Examples - /// - /// ``` - /// let mut document = northstar::Document::new(); - /// - /// document.add_link_without_label("https://wikipedia.org"); - /// - /// assert_eq!(document.to_string(), "=> https://wikipedia.org/\n"); - /// ``` - pub fn add_link_without_label<'a, U>(&mut self, uri: U) -> &mut Self - where - U: TryInto>, - { - let uri = uri - .try_into() - .map(URIReference::into_owned) - .or_else(|_| ".".try_into()).expect("Northstar BUG"); - let link = Link { - uri: Box::new(uri), - label: None, - }; - let link = Item::Link(link); - - self.add_item(link); - - self - } - - /// Adds a block of preformatted text. - /// - /// Lines that start with ` ``` ` will be prependend with a whitespace. - /// - /// # Examples - /// - /// ``` - /// let mut document = northstar::Document::new(); - /// - /// document.add_preformatted("a\n b\n c"); - /// - /// assert_eq!(document.to_string(), "```\na\n b\n c\n```\n"); - /// ``` - pub fn add_preformatted(&mut self, preformatted_text: impl AsRef) -> &mut Self { - self.add_preformatted_with_alt("", preformatted_text.as_ref()) - } - - /// Adds a block of preformatted text with an alt text. - /// - /// Consecutive newlines in `alt` will be replaced - /// with a single whitespace. - /// - /// `preformatted_text` lines that start with ` ``` ` - /// will be prependend with a whitespace. - /// - /// # Examples - /// - /// ``` - /// let mut document = northstar::Document::new(); - /// - /// document.add_preformatted_with_alt("rust", "fn main() {\n}\n"); - /// - /// assert_eq!(document.to_string(), "```rust\nfn main() {\n}\n```\n"); - /// ``` - pub fn add_preformatted_with_alt(&mut self, alt: impl AsRef, preformatted_text: impl AsRef) -> &mut Self { - let alt = AltText::new_lossy(alt.as_ref()); - let lines = preformatted_text - .as_ref() - .lines() - .map(PreformattedText::new_lossy) - .collect(); - let preformatted = Preformatted { - alt, - lines, - }; - let preformatted = Item::Preformatted(preformatted); - - self.add_item(preformatted); - - self - } - - /// Adds a heading. - /// - /// Consecutive newlines in `text` will be replaced - /// with a single whitespace. - /// - /// # Examples - /// - /// ``` - /// use northstar::document::HeadingLevel::H1; - /// - /// let mut document = northstar::Document::new(); - /// - /// document.add_heading(H1, "Welcome!"); - /// - /// assert_eq!(document.to_string(), "# Welcome!\n"); - /// ``` - pub fn add_heading(&mut self, level: HeadingLevel, text: impl Cowy) -> &mut Self { - let text = HeadingText::new_lossy(text); - let heading = Heading { - level, - text, - }; - let heading = Item::Heading(heading); - - self.add_item(heading); - - self - } - - /// Adds an unordered list item. - /// - /// Consecutive newlines in `text` will be replaced - /// with a single whitespace. - /// - /// # Examples - /// - /// ``` - /// let mut document = northstar::Document::new(); - /// - /// document.add_unordered_list_item("milk"); - /// document.add_unordered_list_item("eggs"); - /// - /// assert_eq!(document.to_string(), "* milk\n* eggs\n"); - /// ``` - pub fn add_unordered_list_item(&mut self, text: impl AsRef) -> &mut Self { - let item = UnorderedListItem::new_lossy(text.as_ref()); - let item = Item::UnorderedListItem(item); - - self.add_item(item); - - self - } - - /// Adds a quote. - /// - /// This function allows adding multiple quote lines at once. - /// - /// # Examples - /// - /// ``` - /// let mut document = northstar::Document::new(); - /// - /// document.add_quote("I think,\ntherefore I am"); - /// - /// assert_eq!(document.to_string(), "> I think,\n> therefore I am\n"); - /// ``` - pub fn add_quote(&mut self, text: impl AsRef) -> &mut Self { - let quote = text - .as_ref() - .lines() - .map(Quote::new_lossy) - .map(Item::Quote); - - self.add_items(quote); - - self - } -} - -impl fmt::Display for Document { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for item in &self.items { - match item { - Item::Text(text) => writeln!(f, "{}", text.0)?, - Item::Link(link) => { - let separator = if link.label.is_some() {" "} else {""}; - let label = link.label.as_ref().map(|label| label.0.as_str()) - .unwrap_or(""); - - writeln!(f, "=> {}{}{}", link.uri, separator, label)?; - } - Item::Preformatted(preformatted) => { - writeln!(f, "```{}", preformatted.alt.0)?; - - for line in &preformatted.lines { - writeln!(f, "{}", line.0)?; - } - - writeln!(f, "```")? - } - Item::Heading(heading) => { - let level = match heading.level { - HeadingLevel::H1 => "#", - HeadingLevel::H2 => "##", - HeadingLevel::H3 => "###", - }; - - writeln!(f, "{} {}", level, heading.text.0)?; - } - Item::UnorderedListItem(item) => writeln!(f, "* {}", item.0)?, - Item::Quote(quote) => writeln!(f, "> {}", quote.0)?, - } - } - - Ok(()) - } -} - -#[allow(clippy::enum_variant_names)] -enum Item { - Text(Text), - Link(Link), - Preformatted(Preformatted), - Heading(Heading), - UnorderedListItem(UnorderedListItem), - Quote(Quote), -} - -#[derive(Default)] -struct Text(String); - -impl Text { - fn blank() -> Self { - Self::default() - } - - fn new_lossy(line: impl Cowy) -> Self { - Self(lossy_escaped_line(line, SPECIAL_STARTS)) - } -} - -struct Link { - uri: Box>, - label: Option, -} - -struct LinkLabel(String); - -impl LinkLabel { - fn from_lossy(line: impl Cowy) -> Self { - let line = strip_newlines(line); - - Self(line) - } -} - -struct Preformatted { - alt: AltText, - lines: Vec, -} - -struct PreformattedText(String); - -impl PreformattedText { - fn new_lossy(line: impl Cowy) -> Self { - Self(lossy_escaped_line(line, &[PREFORMATTED_TOGGLE_START])) - } -} - -struct AltText(String); - -impl AltText { - fn new_lossy(alt: &str) -> Self { - let alt = strip_newlines(alt); - - Self(alt) - } -} - -struct Heading { - level: HeadingLevel, - text: HeadingText, -} - -/// The level of a heading. -pub enum HeadingLevel { - /// Heading level 1 (`#`) - H1, - /// Heading level 2 (`##`) - H2, - /// Heading level 3 (`###`) - H3, -} - -struct HeadingText(String); - -impl HeadingText { - fn new_lossy(line: impl Cowy) -> Self { - let line = strip_newlines(line); - - Self(line) - } -} - -struct UnorderedListItem(String); - -impl UnorderedListItem { - fn new_lossy(text: &str) -> Self { - let text = strip_newlines(text); - - Self(text) - } -} - -struct Quote(String); - -impl Quote { - fn new_lossy(text: &str) -> Self { - Self(lossy_escaped_line(text, &[QUOTE_START])) - } -} - - -const LINK_START: &str = "=>"; -const PREFORMATTED_TOGGLE_START: &str = "```"; -const HEADING_START: &str = "#"; -const UNORDERED_LIST_ITEM_START: &str = "*"; -const QUOTE_START: &str = ">"; - -const SPECIAL_STARTS: &[&str] = &[ - LINK_START, - PREFORMATTED_TOGGLE_START, - HEADING_START, - UNORDERED_LIST_ITEM_START, - QUOTE_START, -]; - -fn starts_with_any(s: &str, starts: &[&str]) -> bool { - for start in starts { - if s.starts_with(start) { - return true; - } - } - - false -} - -fn lossy_escaped_line(line: impl Cowy, escape_starts: &[&str]) -> String { - let line_ref = line.as_ref(); - let contains_newline = line_ref.contains('\n'); - let has_special_start = starts_with_any(line_ref, escape_starts); - - if !contains_newline && !has_special_start { - return line.into(); - } - - let mut line = String::new(); - - if has_special_start { - line.push(' '); - } - - if let Some(line_ref) = line_ref.split('\n').next() { - line.push_str(line_ref); - } - - line -} - -fn strip_newlines(text: impl Cowy) -> String { - if !text.as_ref().contains(&['\r', '\n'][..]) { - return text.into(); - } - - text.as_ref() - .lines() - .filter(|part| !part.is_empty()) - .collect::>() - .join(" ") -} diff --git a/src/types/response.rs b/src/types/response.rs index fe8326f..4e42898 100644 --- a/src/types/response.rs +++ b/src/types/response.rs @@ -1,9 +1,8 @@ use std::convert::TryInto; -use std::borrow::Borrow; use anyhow::*; use uriparse::URIReference; -use crate::types::{ResponseHeader, Body, Mime, Document}; +use crate::types::{ResponseHeader, Body, Mime}; use crate::util::Cowy; use crate::GEMINI_MIME; @@ -20,14 +19,6 @@ impl Response { } } - #[deprecated( - since = "0.4.0", - note = "Deprecated in favor of Response::success_gemini() or Document::into()" - )] - pub fn document(document: impl Borrow) -> Self { - Self::success_gemini(document) - } - pub fn input(prompt: impl Cowy) -> Result { let header = ResponseHeader::input(prompt)?; Ok(Self::new(header)) @@ -100,8 +91,9 @@ impl Response { } } -impl> From for Response { +#[cfg(feature = "gemtext")] +impl>> From for Response { fn from(doc: D) -> Self { - Self::success_gemini(doc) + Self::success_gemini(doc.into()) } } diff --git a/src/util.rs b/src/util.rs index 7af5a20..872d343 100644 --- a/src/util.rs +++ b/src/util.rs @@ -8,8 +8,6 @@ use tokio::{ fs::{self, File}, io, }; -#[cfg(feature="serve_dir")] -use crate::types::{Document, document::HeadingLevel::*}; use crate::types::Response; use std::panic::{catch_unwind, AssertUnwindSafe}; use std::task::Poll; @@ -103,13 +101,13 @@ async fn serve_dir_listing, B: AsRef>(path: P, virtual_path }; let breadcrumbs: PathBuf = virtual_path.iter().collect(); - let mut document = Document::new(); + let mut document = gemtext::Builder::new(); - document.add_heading(H1, format!("Index of /{}", breadcrumbs.display())); - document.add_blank_line(); + document = document.heading(1, format!("Index of /{}", breadcrumbs.display())) + .blank_line(); if virtual_path.get(0).map(<_>::as_ref) != Some(Path::new("")) { - document.add_link("..", "📁 ../"); + document = document.link("..", Some("📁 ../")); } while let Some(entry) = dir.next_entry().await.context("Failed to list directory")? { @@ -121,11 +119,11 @@ async fn serve_dir_listing, B: AsRef>(path: P, virtual_path let trailing_slash = if is_dir { "/" } else { "" }; let uri = format!("./{}{}", file_name, trailing_slash); - document.add_link(uri.as_str(), format!("{icon} {name}{trailing_slash}", + document = document.link(uri.as_str(), Some(format!("{icon} {name}{trailing_slash}", icon = if is_dir { '📁' } else { '📄' }, name = file_name, trailing_slash = trailing_slash - )); + ))); } Ok(document.into())