diff --git a/Cargo.lock b/Cargo.lock index b7ca2ff..d4702fa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -743,15 +743,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "html-escape" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" -dependencies = [ - "utf8-width", -] - [[package]] name = "html5ever" version = "0.26.0" @@ -1057,6 +1048,28 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +[[package]] +name = "maud" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa" +dependencies = [ + "itoa", + "maud_macros", +] + +[[package]] +name = "maud_macros" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.52", +] + [[package]] name = "memchr" version = "2.7.1" @@ -1077,7 +1090,7 @@ dependencies = [ "eyre", "fend-core", "futures", - "html-escape", + "maud", "numbat", "once_cell", "rand", @@ -1434,6 +1447,29 @@ dependencies = [ "ryu_floating_decimal", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.79" @@ -2337,12 +2373,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "utf8-width" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" - [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index b63c34e..7008f32 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ chrono-tz = { version = "0.8.6", features = ["case-insensitive"] } eyre = "0.6.12" fend-core = "1.4.5" futures = "0.3.30" -html-escape = "0.2.13" +maud = "0.26.0" numbat = "1.11.0" once_cell = "1.19.0" rand = "0.8.5" diff --git a/README b/README index 22a2f0b..3c15186 100644 --- a/README +++ b/README @@ -2,8 +2,7 @@ a cute metasearch engine it sources from google, bing, brave, and a few others. -it's written in rust using no templating engine and with as little client-side -javascript as possible. +it's written in rust, using as little client-side javascript as possible. there's a demo instance at https://s.matdoes.dev, but don't use it as your default or rely on it, please (so i don't get ratelimited by google). diff --git a/src/engines/answer/dictionary.rs b/src/engines/answer/dictionary.rs index db432dd..34efcbe 100644 --- a/src/engines/answer/dictionary.rs +++ b/src/engines/answer/dictionary.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use eyre::eyre; +use maud::{html, PreEscaped}; use serde::Deserialize; use url::Url; @@ -67,18 +68,10 @@ pub fn parse_response( let word = key_to_title(mediawiki_key); - let mut html = String::new(); - let Some(entries) = res.0.get("en") else { return Ok(EngineResponse::new()); }; - html.push_str(&format!( - "

{word}

", - mediawiki_key = html_escape::encode_safe(mediawiki_key), - word = html_escape::encode_safe(&word), - )); - let mut cleaner = ammonia::Builder::default(); cleaner .link_rel(None) @@ -86,11 +79,28 @@ pub fn parse_response( Url::parse("https://en.wiktionary.org").unwrap(), )); + let mut html = String::new(); + + html.push_str( + &html! { + h2."answer-dictionary-word" { + a href={ "https://en.wiktionary.org/wiki/" (mediawiki_key) } { + (word) + } + } + } + .into_string(), + ); + for entry in entries { - html.push_str(&format!( - "{part_of_speech}", - part_of_speech = html_escape::encode_safe(&entry.part_of_speech.to_lowercase()) - )); + html.push_str( + &html! { + span."answer-dictionary-part-of-speech" { + (entry.part_of_speech.to_lowercase()) + } + } + .into_string(), + ); html.push_str("
    "); let mut previous_definitions = Vec::::new(); @@ -113,12 +123,19 @@ pub fn parse_response( .clean(&definition.definition.replace('“', "\"")) .to_string(); - html.push_str(&format!("

    {definition_html}

    ")); + html.push_str(&html! { p { (PreEscaped(definition_html)) } }.into_string()); if !definition.examples.is_empty() { for example in &definition.examples { let example_html = cleaner.clean(example).to_string(); - html.push_str(&format!("
    {example_html}
    ")); + html.push_str( + &html! { + blockquote."answer-dictionary-example" { + (PreEscaped(example_html)) + } + } + .into_string(), + ); } } html.push_str(""); @@ -126,7 +143,7 @@ pub fn parse_response( html.push_str("
"); } - Ok(EngineResponse::answer_html(html)) + Ok(EngineResponse::answer_html(PreEscaped(html))) } fn key_to_title(key: &str) -> String { diff --git a/src/engines/answer/fend.rs b/src/engines/answer/fend.rs index 2709d27..99e8e33 100644 --- a/src/engines/answer/fend.rs +++ b/src/engines/answer/fend.rs @@ -1,6 +1,7 @@ use std::cell::Cell; use fend_core::SpanKind; +use maud::{html, PreEscaped}; use once_cell::sync::Lazy; use crate::engines::EngineResponse; @@ -10,15 +11,14 @@ use super::regex; pub fn request(query: &str) -> EngineResponse { let query = clean_query(query); - let Some(result_html) = evaluate(&query, true) else { + let Some(result_html) = evaluate_to_html(&query, true) else { return EngineResponse::new(); }; - EngineResponse::answer_html(format!( - r#"

{query} =

-

{result_html}

"#, - query = html_escape::encode_safe(&query), - )) + EngineResponse::answer_html(html! { + p."answer-query" { (query) " =" } + h3 { b { (result_html) } } + }) } pub fn request_autocomplete(query: &str) -> Vec { @@ -26,7 +26,7 @@ pub fn request_autocomplete(query: &str) -> Vec { let query = clean_query(query); - if let Some(result) = evaluate(&query, false) { + if let Some(result) = evaluate_to_plaintext(&query, false) { results.push(format!("= {result}")); } @@ -43,20 +43,24 @@ pub struct Span { pub kind: SpanKind, } -fn evaluate(query: &str, html: bool) -> Option { +fn evaluate_to_plaintext(query: &str, html: bool) -> Option { let spans = evaluate_into_spans(query, html); - if spans.is_empty() { return None; } - if !html { - return Some( - spans - .iter() - .map(|span| span.text.clone()) - .collect::(), - ); + return Some( + spans + .iter() + .map(|span| span.text.clone()) + .collect::(), + ); +} + +fn evaluate_to_html(query: &str, html: bool) -> Option> { + let spans = evaluate_into_spans(query, html); + if spans.is_empty() { + return None; } let mut result_html = String::new(); @@ -69,12 +73,16 @@ fn evaluate(query: &str, html: bool) -> Option { _ => "", }; if class.is_empty() { - result_html.push_str(&html_escape::encode_safe(&span.text)); + result_html.push_str(&html! { (span.text) }.into_string()); } else { - result_html.push_str(&format!( - r#"{text}"#, - text = html_escape::encode_safe(&span.text) - )); + result_html.push_str( + &html! { + span.(class) { + (span.text) + } + } + .into_string(), + ); } } @@ -86,11 +94,16 @@ fn evaluate(query: &str, html: bool) -> Option { { let hex = spans[0].text.trim_start_matches("0x"); if let Ok(num) = u64::from_str_radix(hex, 16) { - result_html.push_str(&format!(r#" = {num}"#)); + result_html.push_str( + &html! { + span."answer-comment" { " = " (num) } + } + .into_string(), + ); } } - Some(result_html) + Some(PreEscaped(result_html)) } pub static FEND_CONTEXT: Lazy = Lazy::new(|| { diff --git a/src/engines/answer/ip.rs b/src/engines/answer/ip.rs index 52d13f6..88cde5f 100644 --- a/src/engines/answer/ip.rs +++ b/src/engines/answer/ip.rs @@ -1,3 +1,5 @@ +use maud::html; + use crate::engines::{EngineResponse, SearchQuery}; use super::regex; @@ -9,8 +11,7 @@ pub fn request(query: &SearchQuery) -> EngineResponse { let ip = &query.ip; - EngineResponse::answer_html(format!( - r#"

{ip}

"#, - ip = html_escape::encode_safe(ip) - )) + EngineResponse::answer_html(html! { + h3 { b { (ip) } } + }) } diff --git a/src/engines/answer/notepad.rs b/src/engines/answer/notepad.rs index 425dc12..9f75a89 100644 --- a/src/engines/answer/notepad.rs +++ b/src/engines/answer/notepad.rs @@ -1,3 +1,5 @@ +use maud::html; + use crate::engines::{EngineResponse, SearchQuery}; use super::regex; @@ -10,5 +12,7 @@ pub fn request(query: &SearchQuery) -> EngineResponse { // This allows pasting styles which is undesired behavior, and the // `contenteditable="plaintext-only"` attribute currently only works on Chrome. // This should be updated when the attribute becomes available in more browsers - EngineResponse::answer_html(r#"
"#.to_string()) + EngineResponse::answer_html(html! { + div."answer-notepad" contenteditable="plaintext-only" {} + }) } diff --git a/src/engines/answer/numbat.rs b/src/engines/answer/numbat.rs index 2d352d9..c429b51 100644 --- a/src/engines/answer/numbat.rs +++ b/src/engines/answer/numbat.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use fend_core::SpanKind; +use maud::{html, PreEscaped}; use numbat::{ markup::{FormatType, FormattedString, Markup}, pretty_print::PrettyPrint, @@ -23,10 +24,10 @@ pub fn request(query: &str) -> EngineResponse { return EngineResponse::new(); }; - EngineResponse::answer_html(format!( - r#"

{query_html} =

-

{result_html}

"# - )) + EngineResponse::answer_html(html! { + p."answer-query" { (query_html) " =" } + h3 { b { (result_html) } } + }) } pub fn request_autocomplete(query: &str) -> Vec { @@ -119,8 +120,8 @@ fn evaluate_for_autocomplete(query: &str) -> Option { } pub struct NumbatResponse { - pub query_html: String, - pub result_html: String, + pub query_html: PreEscaped, + pub result_html: PreEscaped, } fn evaluate(query: &str) -> Option { @@ -155,7 +156,7 @@ fn fix_markup(markup: Markup) -> Markup { Markup(reordered_markup) } -fn markup_to_html(markup: Markup) -> String { +fn markup_to_html(markup: Markup) -> PreEscaped { let mut html = String::new(); for FormattedString(_, format_type, content) in markup.0 { let class = match format_type { @@ -165,15 +166,17 @@ fn markup_to_html(markup: Markup) -> String { _ => "", }; if class.is_empty() { - html.push_str(&html_escape::encode_safe(&content)); + html.push_str(&html! {(content)}.into_string()); } else { - html.push_str(&format!( - r#"{content}"#, - content = html_escape::encode_safe(&content) - )); + html.push_str( + &html! { + span.(class) { (content) } + } + .into_string(), + ); } } - html + PreEscaped(html) } pub static NUMBAT_CTX: Lazy = Lazy::new(|| { diff --git a/src/engines/answer/thesaurus.rs b/src/engines/answer/thesaurus.rs index 139dfd6..2f3ac71 100644 --- a/src/engines/answer/thesaurus.rs +++ b/src/engines/answer/thesaurus.rs @@ -1,4 +1,5 @@ use eyre::eyre; +use maud::{html, PreEscaped}; use scraper::{Html, Selector}; use serde::Deserialize; use tracing::error; @@ -149,23 +150,24 @@ fn parse_thesaurus_com_item( }) } -fn render_thesaurus_html(ThesaurusResponse { word, items }: ThesaurusResponse) -> String { - let mut html = String::new(); +fn render_thesaurus_html( + ThesaurusResponse { word, items }: ThesaurusResponse, +) -> PreEscaped { + html! { + h2."answer-thesaurus-word" { + a href={ "https://www.thesaurus.com/browse/" (word) } { + (word) + } + } + div."answer-thesaurus-items" { + @for item in items { + div."answer-thesaurus-item" { + (render_thesaurus_item_html(item)) + } + } + } - html.push_str(&format!( - "

{word}

", - word = html_escape::encode_safe(&word) - )); - - html.push_str("
"); - for item in items { - html.push_str("
"); - html.push_str(&render_thesaurus_item_html(item)); - html.push_str("
"); } - html.push_str("
"); - - html } fn render_thesaurus_item_html( @@ -176,53 +178,46 @@ fn render_thesaurus_item_html( strong_matches, weak_matches, }: ThesaurusItem, -) -> String { +) -> PreEscaped { let mut html = String::new(); - html.push_str(&format!( - "{part_of_speech}, as in {as_in}", - part_of_speech = html_escape::encode_safe(&part_of_speech.to_lowercase()), - as_in = html_escape::encode_safe(&as_in) - )); + html.push_str( + &html! { + span."answer-thesaurus-word-description" { + span."answer-thesaurus-part-of-speech" { (part_of_speech.to_lowercase()) } + ", as in " + span."answer-thesaurus-as-in" { (as_in) } + } + } + .into_string(), + ); let render_matches = |matches: Vec, strength: &str| { if matches.is_empty() { - return String::new(); + return PreEscaped::default(); } - let mut html = String::new(); - - html.push_str(&format!( - "
", - strength_id = html_escape::encode_safe(&strength.to_lowercase().replace(' ', "-")) - )); - - html.push_str(&format!( - "

{strength} {match_or_matches}

", - strength = html_escape::encode_safe(&strength), - match_or_matches = if matches.len() == 1 { - "match" - } else { - "matches" + html! { + div.{ "answer-thesaurus-" (strength.to_lowercase().replace(' ', "-")) } { + h3."answer-thesaurus-category-title" { + (strength) + " " + (if matches.len() == 1 { "match" } else { "matches" }) + } + ul."answer-thesaurus-list" { + @for synonym in matches { + li { + a href={ "https://www.thesaurus.com/browse/" (synonym) } { (synonym) } + } + } + } } - )); - html.push_str("
    "); - for synonym in matches { - html.push_str(&format!( - "
  • {synonym}
  • ", - synonym = html_escape::encode_safe(&synonym) - )); } - html.push_str("
"); - - html.push_str("
"); - - html }; - html.push_str(&render_matches(strongest_matches, "Strongest")); - html.push_str(&render_matches(strong_matches, "Strong")); - html.push_str(&render_matches(weak_matches, "Weak")); + html.push_str(&render_matches(strongest_matches, "Strongest").into_string()); + html.push_str(&render_matches(strong_matches, "Strong").into_string()); + html.push_str(&render_matches(weak_matches, "Weak").into_string()); - html + PreEscaped(html) } diff --git a/src/engines/answer/timezone.rs b/src/engines/answer/timezone.rs index a845336..9f3c413 100644 --- a/src/engines/answer/timezone.rs +++ b/src/engines/answer/timezone.rs @@ -1,5 +1,6 @@ use chrono::{DateTime, TimeZone}; use chrono_tz::{OffsetComponents, Tz}; +use maud::html; use crate::engines::EngineResponse; @@ -8,13 +9,15 @@ use super::regex; pub fn request(query: &str) -> EngineResponse { match evaluate(query) { None => EngineResponse::new(), - Some(TimeResponse::Current { time, timezone }) => EngineResponse::answer_html(format!( - r#"

Current time in {timezone}

-

{time} ({date})

"#, - time = html_escape::encode_safe(&time.format("%-I:%M %P").to_string()), - date = html_escape::encode_safe(&time.format("%B %-d").to_string()), - timezone = html_escape::encode_safe(&timezone_to_string(timezone)), - )), + Some(TimeResponse::Current { time, timezone }) => EngineResponse::answer_html(html! { + p."answer-query" { "Current time in " (timezone_to_string(timezone)) } + h3 { + b { (time.format("%-I:%M %P")) } + span."answer-comment" { + "(" (time.format("%B %-d")) ")" + } + } + }), Some(TimeResponse::Conversion { source_timezone, target_timezone, @@ -22,22 +25,31 @@ pub fn request(query: &str) -> EngineResponse { target_time, source_offset, target_offset, - }) => EngineResponse::answer_html(format!( - r#"

{source_time} {source_timezone} to {target_timezone}

-

{target_time} {target_timezone} ({delta})

"#, - source_time = html_escape::encode_safe(&source_time.format("%-I:%M %P").to_string()), - target_time = html_escape::encode_safe(&target_time.format("%-I:%M %P").to_string()), - source_timezone = html_escape::encode_safe(&timezone_to_string(source_timezone)), - target_timezone = html_escape::encode_safe(&timezone_to_string(target_timezone)), - delta = html_escape::encode_safe(&{ - let delta_minutes = (target_offset - source_offset).num_minutes(); - if delta_minutes % 60 == 0 { - format!("{:+}", delta_minutes / 60) - } else { - format!("{:+}:{}", delta_minutes / 60, delta_minutes % 60) + }) => { + let delta_minutes = (target_offset - source_offset).num_minutes(); + let delta = if delta_minutes % 60 == 0 { + format!("{:+}", delta_minutes / 60) + } else { + format!("{:+}:{}", delta_minutes / 60, delta_minutes % 60) + }; + + EngineResponse::answer_html(html! { + p."answer-query" { + (source_time.format("%-I:%M %P")) + " " + (timezone_to_string(source_timezone)) + " to " + (timezone_to_string(target_timezone)) + } + h3 { + b { (target_time.format("%-I:%M %P")) } + " " + span."answer-comment" { + (timezone_to_string(target_timezone)) " (" (delta) ")" + } } }) - )), + } } } diff --git a/src/engines/answer/useragent.rs b/src/engines/answer/useragent.rs index ee9418c..9cc6583 100644 --- a/src/engines/answer/useragent.rs +++ b/src/engines/answer/useragent.rs @@ -1,3 +1,5 @@ +use maud::html; + use crate::engines::{EngineResponse, SearchQuery}; use super::regex; @@ -12,11 +14,10 @@ pub fn request(query: &SearchQuery) -> EngineResponse { let user_agent = query.request_headers.get("user-agent"); EngineResponse::answer_html(if let Some(user_agent) = user_agent { - format!( - "

{user_agent}

", - user_agent = html_escape::encode_safe(user_agent) - ) + html! { + h3 { b { (user_agent) } } + } } else { - "You don't have a user agent".to_string() + html! { "You don't have a user agent" } }) } diff --git a/src/engines/answer/wikipedia.rs b/src/engines/answer/wikipedia.rs index e956683..ed6ff7b 100644 --- a/src/engines/answer/wikipedia.rs +++ b/src/engines/answer/wikipedia.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use maud::html; use serde::Deserialize; use url::Url; @@ -89,10 +90,10 @@ pub fn parse_response(body: &str) -> eyre::Result { let page_title = title.replace(' ', "_"); let page_url = format!("https://en.wikipedia.org/wiki/{page_title}"); - Ok(EngineResponse::infobox_html(format!( - r#"

{title}

{extract}

"#, - page_url = html_escape::encode_quoted_attribute(&page_url), - title = html_escape::encode_safe(title), - extract = html_escape::encode_safe(&extract), - ))) + Ok(EngineResponse::infobox_html(html! { + a href=(page_url) { + h2 { (title) } + } + p { (extract) } + })) } diff --git a/src/engines/macros.rs b/src/engines/macros.rs index c8887ba..2f7184d 100644 --- a/src/engines/macros.rs +++ b/src/engines/macros.rs @@ -115,7 +115,7 @@ macro_rules! engine_postsearch_requests { } #[must_use] - pub fn postsearch_parse_response(&self, res: &HttpResponse) -> Option { + pub fn postsearch_parse_response(&self, res: &HttpResponse) -> Option> { match self { $( Engine::$engine => $crate::engine_parse_response! { res, $module::$engine_id::$parse_response }?, diff --git a/src/engines/mod.rs b/src/engines/mod.rs index 10691dd..7a7e123 100644 --- a/src/engines/mod.rs +++ b/src/engines/mod.rs @@ -9,6 +9,7 @@ use std::{ }; use futures::future::join_all; +use maud::PreEscaped; use once_cell::sync::Lazy; use reqwest::header::HeaderMap; use serde::{Deserialize, Deserializer}; @@ -189,8 +190,8 @@ pub struct EngineFeaturedSnippet { pub struct EngineResponse { pub search_results: Vec, pub featured_snippet: Option, - pub answer_html: Option, - pub infobox_html: Option, + pub answer_html: Option>, + pub infobox_html: Option>, } impl EngineResponse { @@ -200,7 +201,7 @@ impl EngineResponse { } #[must_use] - pub fn answer_html(html: String) -> Self { + pub fn answer_html(html: PreEscaped) -> Self { Self { answer_html: Some(html), ..Default::default() @@ -208,7 +209,7 @@ impl EngineResponse { } #[must_use] - pub fn infobox_html(html: String) -> Self { + pub fn infobox_html(html: PreEscaped) -> Self { Self { infobox_html: Some(html), ..Default::default() @@ -497,13 +498,13 @@ pub struct FeaturedSnippet { #[derive(Debug, Clone)] pub struct Answer { - pub html: String, + pub html: PreEscaped, pub engine: Engine, } #[derive(Debug, Clone)] pub struct Infobox { - pub html: String, + pub html: PreEscaped, pub engine: Engine, } diff --git a/src/engines/postsearch/docs_rs.rs b/src/engines/postsearch/docs_rs.rs index fcbea7e..2c0090e 100644 --- a/src/engines/postsearch/docs_rs.rs +++ b/src/engines/postsearch/docs_rs.rs @@ -1,3 +1,4 @@ +use maud::{html, PreEscaped}; use scraper::{Html, Selector}; use crate::engines::{HttpResponse, Response, CLIENT}; @@ -12,7 +13,7 @@ pub fn request(response: &Response) -> Option { None } -pub fn parse_response(HttpResponse { res, body, .. }: &HttpResponse) -> Option { +pub fn parse_response(HttpResponse { res, body, .. }: &HttpResponse) -> Option> { let url = res.url().clone(); let dom = Html::parse_document(body); @@ -53,22 +54,21 @@ pub fn parse_response(HttpResponse { res, body, .. }: &HttpResponse) -> Option{category} {title} {version}"#, - url = html_escape::encode_quoted_attribute(&url.to_string()), - title = html_escape::encode_safe(&title), - version = html_escape::encode_safe(&version), - ) - } else { - format!( - r#"

{category} {title}

"#, - url = html_escape::encode_quoted_attribute(&url.to_string()), - title = html_escape::encode_safe(&title), - ) + let title_html = html! { + h2 { + (category) + " " + a href=(url) { (title) } + @if category == "Crate" { + span."infobox-docs_rs-version" { (version) } + } + } }; - Some(format!( - r#"{title_html}
{doc_html}
"# - )) + Some(html! { + (title_html) + div."infobox-docs_rs-doc" { + (PreEscaped(doc_html)) + } + }) } diff --git a/src/engines/postsearch/github.rs b/src/engines/postsearch/github.rs index 08d3ba5..f2ef363 100644 --- a/src/engines/postsearch/github.rs +++ b/src/engines/postsearch/github.rs @@ -1,3 +1,4 @@ +use maud::{html, PreEscaped}; use scraper::{Html, Selector}; use url::Url; @@ -13,7 +14,7 @@ pub fn request(response: &Response) -> Option { None } -pub fn parse_response(body: &str) -> Option { +pub fn parse_response(body: &str) -> Option> { let dom = Html::parse_document(body); let url_relative = dom @@ -68,10 +69,12 @@ pub fn parse_response(body: &str) -> Option { .collect::() }; - Some(format!( - r#"

{title}

-
{readme_html}
"#, - url = html_escape::encode_quoted_attribute(&url), - title = html_escape::encode_safe(&title), - )) + Some(html! { + a href=(url) { + h1 { (title) } + } + div."infobox-github-readme" { + (PreEscaped(readme_html)) + } + }) } diff --git a/src/engines/postsearch/mdn.rs b/src/engines/postsearch/mdn.rs index 99219bf..1736b6c 100644 --- a/src/engines/postsearch/mdn.rs +++ b/src/engines/postsearch/mdn.rs @@ -1,3 +1,4 @@ +use maud::{html, PreEscaped}; use scraper::{Html, Selector}; use serde::Deserialize; use tracing::error; @@ -22,7 +23,9 @@ pub fn request(response: &Response) -> Option { None } -pub fn parse_response(HttpResponse { res, body, config }: &HttpResponse) -> Option { +pub fn parse_response( + HttpResponse { res, body, config }: &HttpResponse, +) -> Option> { let config_toml = config.engines.get(Engine::Mdn).extra.clone(); let config: MdnConfig = match toml::Value::Table(config_toml).try_into() { Ok(args) => args, @@ -57,7 +60,7 @@ pub fn parse_response(HttpResponse { res, body, config }: &HttpResponse) -> Opti .map(|doc| doc.inner_html()) .take(max_sections) .collect::>() - .join("
"); + .join("
"); let doc_html = ammonia::Builder::default() .link_rel(None) @@ -65,13 +68,16 @@ pub fn parse_response(HttpResponse { res, body, config }: &HttpResponse) -> Opti .clean(&doc_html) .to_string(); - let title_html = format!( - r#"

{title}

"#, - url = html_escape::encode_quoted_attribute(&url.to_string()), - title = html_escape::encode_safe(&page_title), - ); + let title_html = html! { + h2 { + a href=(url) { (page_title) } + } + }; - Some(format!( - r#"{title_html}
{doc_html}
"# - )) + Some(html! { + (title_html) + div."infobox-mdn-article" { + (PreEscaped(doc_html)) + } + }) } diff --git a/src/engines/postsearch/minecraft_wiki.rs b/src/engines/postsearch/minecraft_wiki.rs index 854857f..b29276c 100644 --- a/src/engines/postsearch/minecraft_wiki.rs +++ b/src/engines/postsearch/minecraft_wiki.rs @@ -1,3 +1,4 @@ +use maud::{html, PreEscaped}; use scraper::{Html, Selector}; use crate::engines::{HttpResponse, Response, CLIENT}; @@ -12,7 +13,7 @@ pub fn request(response: &Response) -> Option { None } -pub fn parse_response(HttpResponse { res, body, .. }: &HttpResponse) -> Option { +pub fn parse_response(HttpResponse { res, body, .. }: &HttpResponse) -> Option> { let url = res.url().clone(); let dom = Html::parse_document(body); @@ -41,13 +42,16 @@ pub fn parse_response(HttpResponse { res, body, .. }: &HttpResponse) -> Option{title}"#, - url = html_escape::encode_quoted_attribute(&url.to_string()), - title = html_escape::encode_safe(&page_title), - ); + let title_html = html! { + h2 { + a href=(url) { (page_title) } + } + }; - Some(format!( - r#"{title_html}
{doc_html}
"# - )) + Some(html! { + (title_html) + div."infobox-minecraft_wiki-article" { + (PreEscaped(doc_html)) + } + }) } diff --git a/src/engines/postsearch/stackexchange.rs b/src/engines/postsearch/stackexchange.rs index 6ed8655..f576b81 100644 --- a/src/engines/postsearch/stackexchange.rs +++ b/src/engines/postsearch/stackexchange.rs @@ -1,3 +1,4 @@ +use maud::{html, PreEscaped}; use scraper::{Html, Selector}; use url::Url; @@ -15,7 +16,7 @@ pub fn request(response: &Response) -> Option { None } -pub fn parse_response(body: &str) -> Option { +pub fn parse_response(body: &str) -> Option> { let dom = Html::parse_document(body); let title = dom @@ -55,10 +56,12 @@ pub fn parse_response(body: &str) -> Option { let url = format!("{url}#{answer_id}"); - Some(format!( - r#"

{title}

-
{answer_html}
"#, - url = html_escape::encode_quoted_attribute(&url.to_string()), - title = html_escape::encode_safe(&title), - )) + Some(html! { + a href=(url) { + h2 { (title) } + } + div."infobox-stackexchange-answer" { + (PreEscaped(answer_html)) + } + }) } diff --git a/src/web/assets/index.html b/src/web/assets/index.html deleted file mode 100644 index 99864bd..0000000 --- a/src/web/assets/index.html +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - metasearch - - - - - -
-

metasearch

-
- - -
-
- %version-info% - - diff --git a/src/web/index.rs b/src/web/index.rs new file mode 100644 index 0000000..af35b57 --- /dev/null +++ b/src/web/index.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use axum::{extract::State, http::header, response::IntoResponse}; +use maud::{html, PreEscaped, DOCTYPE}; + +use crate::config::Config; + +const BASE_COMMIT_URL: &str = "https://github.com/mat-1/metasearch2/commit/"; +const VERSION: &str = std::env!("CARGO_PKG_VERSION"); +const COMMIT_HASH: &str = std::env!("GIT_HASH"); +const COMMIT_HASH_SHORT: &str = std::env!("GIT_HASH_SHORT"); + +pub async fn index(State(config): State>) -> impl IntoResponse { + let mut html = String::new(); + html.push_str( + &html! { + (PreEscaped("\n")) + (DOCTYPE) + html lang="en" { + head { + meta charset="UTF-8"; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + title { "metasearch" } + link rel="stylesheet" href="/style.css"; + script src="/script.js" defer {} + link rel="search" type="application/opensearchdescription+xml" title="metasearch" href="/opensearch.xml"; + } + body { + div."main-container" { + h1 { "metasearch" } + form."search-form" action="/search" method="get" { + input type="text" name="q" placeholder="Search" id="search-input" autofocus onfocus="this.select()" autocomplete="off"; + input type="submit" value="Search"; + } + } + @if config.version_info.unwrap() { + span."version-info" { + @if COMMIT_HASH == "unknown" || COMMIT_HASH_SHORT == "unknown" { + "Version " + (VERSION) + } else { + "Version " + (VERSION) + " (" + a href=(format!("{BASE_COMMIT_URL}{COMMIT_HASH}")) { (COMMIT_HASH_SHORT) } + ")" + } + } + } + } + + } + } + .into_string(), + ); + + ([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html) +} diff --git a/src/web/mod.rs b/src/web/mod.rs index 6d3d832..267a0c2 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -1,4 +1,5 @@ pub mod autocomplete; +pub mod index; pub mod opensearch; pub mod search; @@ -9,36 +10,11 @@ use tracing::info; use crate::config::Config; -const BASE_COMMIT_URL: &str = "https://github.com/mat-1/metasearch2/commit/"; -const VERSION: &str = std::env!("CARGO_PKG_VERSION"); -const COMMIT_HASH: &str = std::env!("GIT_HASH"); -const COMMIT_HASH_SHORT: &str = std::env!("GIT_HASH_SHORT"); - pub async fn run(config: Config) { let bind_addr = config.bind; - let version_info = if config.version_info.unwrap() { - if COMMIT_HASH == "unknown" || COMMIT_HASH_SHORT == "unknown" { - format!(r#"Version {VERSION} (unknown commit)"#) - } else { - format!( - r#"Version {VERSION} ({COMMIT_HASH_SHORT})"# - ) - } - } else { - String::new() - }; - let app = Router::new() - .route( - "/", - get(|| async move { - ( - [(header::CONTENT_TYPE, "text/html; charset=utf-8")], - include_str!("assets/index.html").replace("%version-info%", &version_info), - ) - }), - ) + .route("/", get(index::index)) .route( "/style.css", get(|| async { diff --git a/src/web/search.rs b/src/web/search.rs index 5ebe4d4..8883c36 100644 --- a/src/web/search.rs +++ b/src/web/search.rs @@ -8,7 +8,7 @@ use axum::{ response::IntoResponse, }; use bytes::Bytes; -use html_escape::{encode_text, encode_unquoted_attribute}; +use maud::{html, PreEscaped}; use crate::{ config::Config, @@ -16,28 +16,36 @@ use crate::{ }; fn render_beginning_of_html(query: &str) -> String { + let head_html = html! { + head { + meta charset="UTF-8"; + meta name="viewport" content="width=device-width, initial-scale=1.0"; + title { + (query) + " - metasearch" + } + link rel="stylesheet" href="/style.css"; + script src="/script.js" defer {} + link rel="search" type="application/opensearchdescription+xml" title="metasearch" href="/opensearch.xml"; + } + }.into_string(); + let form_html = html! { + form."search-form" action="/search" method="get" { + input #"search-input" type="text" name="q" placeholder="Search" value=(query) autofocus onfocus="this.select()" autocomplete="off"; + input type="submit" value="Search"; + } + }.into_string(); + format!( r#" - - - - {} - metasearch - - - - +{head_html}
-
- - -
+ {form_html}
-"#, - encode_text(query), - encode_unquoted_attribute(query) +"# ) } @@ -45,7 +53,7 @@ fn render_end_of_html() -> String { r"
".to_string() } -fn render_engine_list(engines: &[engines::Engine], config: &Config) -> String { +fn render_engine_list(engines: &[engines::Engine], config: &Config) -> PreEscaped { let mut html = String::new(); let mut first_iter = true; for engine in engines { @@ -59,85 +67,92 @@ fn render_engine_list(engines: &[engines::Engine], config: &Config) -> String { } else { raw_engine_id.to_string() }; - html.push_str(&format!( - r#"{engine}"#, - engine = encode_text(&engine_id) - )); + html.push_str( + &html! { + span."engine-list-item" { + (engine_id) + } + } + .into_string(), + ) + } + html! { + div."engine-list" { + (PreEscaped(html)) + } } - format!(r#"
{html}
"#) } -fn render_search_result(result: &engines::SearchResult, config: &Config) -> String { - format!( - r#"
- - {url} -

{title}

-
-

{desc}

- {engines_html} -
-"#, - url_attr = encode_unquoted_attribute(&result.url), - url = encode_text(&result.url), - title = encode_text(&result.title), - desc = encode_text(&result.description), - engines_html = - render_engine_list(&result.engines.iter().copied().collect::>(), config) - ) +fn render_search_result(result: &engines::SearchResult, config: &Config) -> PreEscaped { + html! { + div."search-result" { + a."search-result-anchor" rel="noreferrer" href=(result.url) { + span."search-result-url" { (result.url) } + h3."search-result-title" { (result.title) } + } + p."search-result-description" { (result.description) } + (render_engine_list(&result.engines.iter().copied().collect::>(), config)) + } + } } -fn render_featured_snippet(featured_snippet: &engines::FeaturedSnippet, config: &Config) -> String { - format!( - r#" -"#, - desc = encode_text(&featured_snippet.description), - url_attr = encode_unquoted_attribute(&featured_snippet.url), - url = encode_text(&featured_snippet.url), - title = encode_text(&featured_snippet.title), - engines_html = render_engine_list(&[featured_snippet.engine], config) - ) +fn render_featured_snippet( + featured_snippet: &engines::FeaturedSnippet, + config: &Config, +) -> PreEscaped { + html! { + div."featured-snippet" { + p."search-result-description" { (featured_snippet.description) } + a."search-result-anchor" rel="noreferrer" href=(featured_snippet.url) { + span."search-result-url" { (featured_snippet.url) } + h3."search-result-title" { (featured_snippet.title) } + } + (render_engine_list(&[featured_snippet.engine], config)) + } + } } -fn render_results(response: Response) -> String { +fn render_results(response: Response) -> PreEscaped { let mut html = String::new(); if let Some(infobox) = &response.infobox { - html.push_str(&format!( - r#"
{infobox_html}{engines_html}
"#, - infobox_html = &infobox.html, - engines_html = render_engine_list(&[infobox.engine], &response.config) - )); + html.push_str( + &html! { + div."infobox" { + (infobox.html) + (render_engine_list(&[infobox.engine], &response.config)) + } + } + .into_string(), + ); } if let Some(answer) = &response.answer { - html.push_str(&format!( - r#"
{answer_html}{engines_html}
"#, - answer_html = &answer.html, - engines_html = render_engine_list(&[answer.engine], &response.config) - )); + html.push_str( + &html! { + div."answer" { + (answer.html) + (render_engine_list(&[answer.engine], &response.config)) + } + } + .into_string(), + ); } if let Some(featured_snippet) = &response.featured_snippet { - html.push_str(&render_featured_snippet(featured_snippet, &response.config)); + html.push_str(&render_featured_snippet(featured_snippet, &response.config).into_string()); } for result in &response.search_results { - html.push_str(&render_search_result(result, &response.config)); + html.push_str(&render_search_result(result, &response.config).into_string()); } - if response.infobox.is_none() - && response.answer.is_none() - && response.featured_snippet.is_none() - && response.search_results.is_empty() - { - html.push_str(r#"

No results.

"#); + if html.is_empty() { + html.push_str( + &html! { + p { "No results." } + } + .into_string(), + ); } - html + PreEscaped(html) } fn render_engine_progress_update( @@ -149,10 +164,22 @@ fn render_engine_progress_update( EngineProgressUpdate::Requesting => "requesting", EngineProgressUpdate::Downloading => "downloading", EngineProgressUpdate::Parsing => "parsing", - EngineProgressUpdate::Done => "done", + EngineProgressUpdate::Done => { + &{ html! { span."progress-update-done" { "done" } }.into_string() } + } }; - format!(r#"{time_ms:>4}ms {engine} {message}"#) + html! { + span."progress-update-time" { + (format!("{time_ms:>4}")) + "ms" + } + " " + (engine) + " " + (PreEscaped(message)) + } + .into_string() } pub async fn route( @@ -235,24 +262,27 @@ pub async fn route( second_part.push_str(""); // close progress-updates second_part.push_str(""); - second_part.push_str(&render_results(results)); + second_part.push_str(&render_results(results).into_string()); yield Ok(Bytes::from(second_part)); }, ProgressUpdateData::PostSearchInfobox(infobox) => { - third_part.push_str(&format!( - r#"
{infobox_html}{engines_html}
"#, - infobox_html = &infobox.html, - engines_html = render_engine_list(&[infobox.engine], &config) - )); + third_part.push_str(&html! { + div."infobox"."postsearch-infobox" { + (infobox.html) + (render_engine_list(&[infobox.engine], &config)) + } + }.into_string()); } } } if let Err(e) = search_future.await? { - let error_html = format!( - r#"

Error: {}

"#, - encode_text(&e.to_string()) - ); + let error_html = html! { + h1 { + "Error: " + (e) + } + }.into_string(); yield R::Ok(Bytes::from(error_html)); return; };