diff --git a/2 b/2 new file mode 100644 index 0000000..1e4cc72 --- /dev/null +++ b/2 @@ -0,0 +1,264 @@ +//! A visitable page on the [`Website`](crate::website::Website). + +use async_trait::async_trait; +use axum::{http::header, response::IntoResponse, routing::get, Extension, Router}; +use futures::future::{BoxFuture, FutureExt}; +use maud::{html, Markup, DOCTYPE}; +use std::collections::HashSet; +use std::sync::Arc; +use tokio::sync::Mutex; +use tracing::{debug, debug_span, instrument, Instrument}; + +use crate::css::Stylesheet; +use crate::js::{self, ScriptType}; +use crate::routes::APIRouter; + +pub mod render_context; +use render_context::ComponentStore; + +pub struct BuiltPage { + #[allow(dead_code)] + name: String, + + head: Markup, + body_renderer: Box BoxFuture<'static, Markup> + Send + Sync>, + + pub used_globals: HashSet, + pub components: Arc>, + + pub api_path: String, + api_router: APIRouter, + + script_path: String, + bundled_script: String, + + style_path: String, + stylesheet: Stylesheet, + + // tasks that need to be awaited before serving content + tasks: Vec>, +} + +impl BuiltPage { + #[instrument(name = "Page::build", skip_all, fields(name = %page.name))] + async fn new(page: Page, path: &str) -> Router { + let base_path = path.trim_end_matches('/'); + let script_path = format!("{}/script.js", base_path); + let style_path = format!("{}/style.css", base_path); + + let api_path = format!("{}/api", base_path); + + let mut bundled_script = String::new(); + for script in &page.extra_scripts { + let script: js::ScriptString = script.into(); + + #[cfg(feature = "minify-js")] + let script = &js::minify_script(script).await; + + bundled_script.push_str(script.as_str()); + } + + let built_page = Self { + name: page.name, + + head: page.head, + body_renderer: page.body_renderer, + + used_globals: HashSet::new(), + components: Arc::new(Mutex::new(ComponentStore::new())), + + api_path, + api_router: APIRouter::new(&format!("{}/api", base_path)), + + script_path, + bundled_script, + + style_path, + stylesheet: Stylesheet::new(), + + tasks: Vec::new(), + }; + + let api_router = built_page.api_router.make_router().await; + let page_extension = Extension(Arc::new(Mutex::new(built_page))); + + // pre-render the page to save request time. this is obviously not guaranteed to prerender all the components, but it should get most of them. + debug!("performing page pre-render"); + let _ = Self::render(page_extension.clone()).await; + + debug!("building router"); + Router::new() + .route("/", get(BuiltPage::render)) + .route("/script.js", get(BuiltPage::script)) + .route("/style.css", get(BuiltPage::style)) + .merge(api_router) + .layer(page_extension) + } + + async fn render(page: Extension>>) -> Markup { + let start = std::time::Instant::now(); + + let mut page_guard = page.lock().await; + + render_context::enter_page(&mut page_guard).await; + let render = (page_guard.body_renderer)().await; + let mut result = render_context::exit_page().await; + + //dbg!(&page.components.lock().unwrap()); + + drop(page_guard); + + let mut tasks = Vec::new(); + let span = debug_span!("Page::task"); + for id in result.new_components.drain() { + let page = page.clone(); + tasks.push(tokio::spawn( + async move { + if page.lock().await.used_globals.contains(&id) { + return; + } + if let Some(component_globals) = render_context::global_store().get(id).await { + if let Some(style) = &component_globals.style { + page.lock().await.stylesheet.add(style); + } + + for script in &component_globals.scripts { + let script: js::ScriptString = script.into(); + + #[cfg(feature = "minify-js")] + let script = &js::minify_script(script).await; + + page.lock().await.bundled_script.push_str(script.as_str()); + } + } + } + .instrument(span.clone()), + )) + } + + let mut page = page.lock().await; + page.tasks.append(&mut tasks); + + for runner in result.runners { + tokio::spawn(runner); + } + + for (route, router) in result.routers.drain(..) { + page.api_router.add_component(route, router).await; + } + + let full_render = html! { + (DOCTYPE) + html lang="en" { + head { + (page.head) + link rel="stylesheet" href=(page.style_path) {} + } + (render) + script src=(page.script_path) {} + } + }; + + debug!("page render took {:?}", start.elapsed()); + + full_render + } + + async fn wait_for_tasks(self: &mut BuiltPage) { + let len = self.tasks.len(); + if len == 0 { + return; + } + + debug!("waiting for {:?} tasks to finish", len); + futures::future::join_all(self.tasks.drain(..)).await; + } + + // Endpoint for serving the bundled script. + async fn script(page: Extension>>) -> impl IntoResponse { + let mut page = page.lock().await; + page.wait_for_tasks().await; + + ( + [(header::CONTENT_TYPE, "application/javascript")], + page.bundled_script.clone(), + ) + } + + // Endpoint for serving the stylesheet. + async fn style(page: Extension>>) -> impl IntoResponse { + let mut page = page.lock().await; + page.wait_for_tasks().await; + + ( + [(header::CONTENT_TYPE, "text/css")], + page.stylesheet.render(), + ) + } +} + +/// A page represents a visitable route on the website. +/// +/// It manages rendering of the content, preparing [scripts](ScriptType) and running components. +pub struct Page { + name: String, + + head: Markup, + body_renderer: Box BoxFuture<'static, Markup> + Send + Sync>, + + extra_scripts: HashSet, +} + +impl Page { + /// Create a new page. + /// + /// The name is only used for logging purposes. + pub fn new(name: &str) -> Self { + let mut extra_scripts = HashSet::new(); + extra_scripts.insert(ScriptType::Inline(include_str!("../htmx/dist/htmx.js"))); + + Self { + name: name.into(), + + head: html! {}, + body_renderer: Box::new(|| { + async { + html! {} + } + .boxed() + }), + + extra_scripts, + } + } + + pub fn with_head(mut self, head: Markup) -> Self { + self.head = head; + self + } + + /// Add content to the page. + /// + /// This function takes in a closure that returns a rendered page. + pub fn with_body(mut self, content_renderer: C) -> Self + where + C: Fn() -> BoxFuture<'static, Markup> + Send + Sync + 'static, + { + self.body_renderer = Box::new(content_renderer); + self + } +} + +/// Allows attaching a page to a router. +#[async_trait] +pub trait RouterPageExt { + /// Attach the given page to the router. This involves building the page and adding multiple routes for the api, scripts and content. + async fn attach_page(self, path: &str, page: Page) -> Self; +} + +#[async_trait] +impl RouterPageExt for Router { + async fn attach_page(self, path: &str, page: Page) -> Self { + BuiltPage::new(page, path).await + } +} diff --git a/fishnet-macros/src/css/ast.rs b/fishnet-macros/src/css/ast.rs index 6812706..9b4bf09 100644 --- a/fishnet-macros/src/css/ast.rs +++ b/fishnet-macros/src/css/ast.rs @@ -1,28 +1,102 @@ +use proc_macro2::TokenStream; use proc_macro_error::{emit_error, SpanRange}; +use quote::{quote, ToTokens}; -pub trait ToFmt { - fn to_fmt(&self) -> String; - fn indent(input: String) -> String { - input - .lines() - .map(|line| { - if !line.is_empty() { - format!(" {}", line) - } else { - line.to_string() - } - }) - .collect::>() - .join("\n") +#[derive(Debug)] +pub struct StyleFmt { + indent_lvl: usize, + style: String, + media_queries: Vec<(String, String)>, +} + +impl StyleFmt { + pub fn new() -> Self { + Self { + indent_lvl: 0, + style: String::new(), + media_queries: Vec::new(), + } + } + + fn finish(&mut self) { + self.style = self.style.trim_end().to_string(); + self.style.push('\n'); + } + // i hate this... + fn push_style(&mut self, style: &str) { + self.style.push_str(&self.indent(style)); + } + fn push_style_no_newline(&mut self, style: &str) { + self.style.push_str(&self.indent(style).trim_end()); + } + fn push_style_no_indent(&mut self, style: &str) { + self.style.push_str(style); + } + + fn push_media_query(&mut self, query: &str, style: &str) { + self.media_queries + .push((query.to_string(), style.to_string())); + } + + fn enter_indent(&mut self) { + self.indent_lvl += 1; + } + fn exit_indent(&mut self) { + self.indent_lvl -= 1; + } + + fn indent(&self, input: &str) -> String { + let indent = " ".repeat(self.indent_lvl); + + let mut out = String::with_capacity(input.len()); + + for line in input.lines() { + if line.is_empty() { + out.push('\n'); + continue; + } + + out.push_str(&indent); + out.push_str(line); + out.push('\n'); + } + + out + } +} + +impl ToTokens for StyleFmt { + fn to_tokens(&self, tokens: &mut TokenStream) { + let mut style_stream = TokenStream::new(); + + let style = &self.style; + + style_stream.extend(quote! { + #style + }); + + let mut media_queries = TokenStream::new(); + for (query, style) in &self.media_queries { + media_queries.extend(quote! { + ( #query, #style ), + }); + } + + tokens.extend(quote! { + #style, + &[#media_queries] + }); } } +pub trait ToFmt { + fn to_fmt(&self, style: &mut StyleFmt); +} + #[derive(Debug)] pub(crate) struct Ruleset(pub Vec); impl ToFmt for Ruleset { - fn to_fmt(&self) -> String { - let mut out = String::new(); - + fn to_fmt(&self, style: &mut StyleFmt) { // bring top-level declarations to the top and wrap them in a single block let mut top_level_declarations = Vec::new(); for fragment in &self.0 { @@ -34,29 +108,27 @@ impl ToFmt for Ruleset { } } if !top_level_declarations.is_empty() { - out.push_str( - &StyleFragment::QualifiedRule(QualifiedRule { - selector: Selector("".to_string()), - declarations: top_level_declarations - .iter() - .map(|declaration| Declaration { - property: declaration.property.clone(), - value: declaration.value.clone(), - }) - .collect(), - }) - .to_fmt(), - ); + StyleFragment::QualifiedRule(QualifiedRule { + selector: Selector("".to_string()), + declarations: top_level_declarations + .iter() + .map(|declaration| Declaration { + property: declaration.property.clone(), + value: declaration.value.clone(), + }) + .collect(), + }) + .to_fmt(style); } for fragment in &self.0 { match fragment { StyleFragment::TopLevelDeclaration(_) => {} - _ => out.push_str(&fragment.to_fmt()), + _ => fragment.to_fmt(style), } } - out + style.finish(); } } @@ -68,16 +140,15 @@ pub(crate) enum StyleFragment { ParseError(SpanRange), } impl ToFmt for StyleFragment { - fn to_fmt(&self) -> String { + fn to_fmt(&self, style: &mut StyleFmt) { match self { - StyleFragment::TopLevelDeclaration(declaration) => declaration.to_fmt(), - StyleFragment::QualifiedRule(rule) => rule.to_fmt(), - StyleFragment::AtRule(at_rule) => at_rule.to_fmt(), + StyleFragment::TopLevelDeclaration(declaration) => declaration.to_fmt(style), + StyleFragment::QualifiedRule(rule) => rule.to_fmt(style), + StyleFragment::AtRule(at_rule) => at_rule.to_fmt(style), StyleFragment::ParseError(span) => { emit_error!(span, "parse error"); - String::new() } - } + }; } } @@ -87,19 +158,20 @@ pub(crate) struct Declaration { pub value: String, } impl ToFmt for Declaration { - fn to_fmt(&self) -> String { - format!("{}: {};", self.property, self.value) + fn to_fmt(&self, style: &mut StyleFmt) { + style.push_style(&format!("{}: {};\n", self.property, self.value)); } } #[derive(Debug)] pub(crate) struct Selector(pub String); impl ToFmt for Selector { - fn to_fmt(&self) -> String { + fn to_fmt(&self, style: &mut StyleFmt) { if self.0.is_empty() { - return ".&".to_string(); + style.push_style_no_newline(".&"); + } else { + style.push_style_no_newline(&format!(".&{}", self.0)); } - format!(".&{}", self.0) } } @@ -109,21 +181,19 @@ pub(crate) struct QualifiedRule { pub declarations: Vec, } impl ToFmt for QualifiedRule { - fn to_fmt(&self) -> String { - let mut out = String::new(); - + fn to_fmt(&self, style: &mut StyleFmt) { if self.declarations.is_empty() { - return out; + return; } - out.push_str(&self.selector.to_fmt()); - out.push_str(" {\n"); + self.selector.to_fmt(style); + style.push_style_no_indent(" {\n"); + style.enter_indent(); for declaration in &self.declarations { - out.push_str(&Self::indent(declaration.to_fmt())); - out.push('\n'); + declaration.to_fmt(style); } - out.push_str("}\n\n"); - out + style.exit_indent(); + style.push_style("}\n\n"); } } @@ -133,10 +203,10 @@ pub(crate) enum AtRule { Other(String), } impl ToFmt for AtRule { - fn to_fmt(&self) -> String { + fn to_fmt(&self, style: &mut StyleFmt) { match self { - AtRule::Media(media_rule) => media_rule.to_fmt(), - AtRule::Other(other) => other.clone(), + AtRule::Media(media_rule) => media_rule.to_fmt(style), + AtRule::Other(other) => style.push_style(&other), } } } @@ -147,11 +217,12 @@ pub(crate) struct MediaRule { pub rules: Ruleset, } impl ToFmt for MediaRule { - fn to_fmt(&self) -> String { - format!( - "@media {}{{\n{}}}\n", - self.condition, - Self::indent(self.rules.to_fmt()) - ) + fn to_fmt(&self, style: &mut StyleFmt) { + let mut inner_style = StyleFmt::new(); + inner_style.enter_indent(); + self.rules.to_fmt(&mut inner_style); + inner_style.exit_indent(); + + style.push_media_query(&self.condition, &inner_style.style); } } diff --git a/fishnet-macros/src/css/mod.rs b/fishnet-macros/src/css/mod.rs index c62eb4b..999a2d4 100644 --- a/fishnet-macros/src/css/mod.rs +++ b/fishnet-macros/src/css/mod.rs @@ -2,4 +2,4 @@ mod parse; pub(crate) use parse::parse; mod ast; -pub(crate) use ast::ToFmt; +pub(crate) use ast::{StyleFmt, ToFmt}; diff --git a/fishnet-macros/src/lib.rs b/fishnet-macros/src/lib.rs index 893a37d..0547b28 100644 --- a/fishnet-macros/src/lib.rs +++ b/fishnet-macros/src/lib.rs @@ -18,14 +18,16 @@ pub fn css(input: proc_macro::TokenStream) -> proc_macro::TokenStream { let input: TokenStream = input.into(); let parsed = css::parse(input); - let parsed = parsed.to_fmt(); + let mut fmt = css::StyleFmt::new(); + parsed.to_fmt(&mut fmt); - quote!({ + let out = quote!({ extern crate fishnet; - fishnet::css::StyleFragment::new(#parsed) - }) - .into() + fishnet::css::StyleFragment::new(#fmt) + }); + + out.into() } #[proc_macro] diff --git a/fishnet/src/component.rs b/fishnet/src/component.rs index 49b7579..236261c 100644 --- a/fishnet/src/component.rs +++ b/fishnet/src/component.rs @@ -95,7 +95,7 @@ where runner: Option>, scripts: Vec, - style: Option, + style: Option>, _renderer_state: PhantomData, _state_state: PhantomData, @@ -139,7 +139,7 @@ where self } - pub fn style(mut self, style: StyleFragment) -> Self { + pub fn style(mut self, style: StyleFragment<'static>) -> Self { self.style = Some(style); self } diff --git a/fishnet/src/css.rs b/fishnet/src/css.rs index d8c62f0..744bab3 100644 --- a/fishnet/src/css.rs +++ b/fishnet/src/css.rs @@ -1,4 +1,6 @@ //! data structures and functions for dealing with css +use std::collections::{hash_map::Entry, HashMap}; +use tracing::debug; /// function for turning a pascal case string into a kebab case string. pub(crate) fn pascal_to_kebab(input: &str) -> String { @@ -18,38 +20,107 @@ pub(crate) fn pascal_to_kebab(input: &str) -> String { out } -/// a special type of string generated using the [`css!`](crate::css!) macro. +/// css generated using the [`css!`](crate::css!) macro. /// -/// currently this internally is a css string with the character `&` being substituted with the top level class used in -/// the render function. this may change at any time, so directly constructing a [`StyleFragment`] without +/// currently this internally is css with the character `&` being substituted with the top level class used in +/// the render function. this may (and probably will) change at any time, so directly constructing a [`StyleFragment`] without /// the [`css!`](crate::css!) macro is strongly discouraged. -pub struct StyleFragment(&'static str); +pub struct StyleFragment<'a> { + style: &'a str, + media_queries: &'a [(&'a str, &'a str)], +} -impl StyleFragment { +impl<'a> StyleFragment<'_> { /// construct a new [`StyleFragment`] using the given string. /// /// since this is normally only used /// via the [`css!`](crate::css!) macro, there is zero validation of the passed in string /// slice! - pub fn new(input: &'static str) -> Self { - Self(input) + pub fn new(style: &'a str, media_queries: &'a [(&'a str, &'a str)]) -> StyleFragment<'a> { + StyleFragment { + style, + media_queries, + } } /// render the [`StyleFragment`] relative to the passed in `toplevel_class`. - pub fn render(&self, toplevel_class: &str) -> StyleString { - StyleString(self.0.to_string().replace("&", toplevel_class)) + pub fn render(&self, toplevel_class: &str) -> RenderedStyle { + RenderedStyle { + style: self.style.replace("&", toplevel_class), + media_queries: self + .media_queries + .iter() + .map(|(query, style)| (query.to_string(), style.replace("&", toplevel_class))) + .collect(), + } } } /// string representation of a rendered [`StyleFragment`]. #[derive(Debug, Clone)] -pub struct StyleString(String); +pub struct RenderedStyle { + style: String, + media_queries: Vec<(String, String)>, +} + +/// a full css stylesheet +/// +/// a stylesheet is basically just a collection of [`RenderedStyle`]s. however it caches all its +/// renders, so you can only add to it and never remove things. +pub struct Stylesheet { + style: String, + rendered_media_queries: String, + + media_queries: HashMap, + media_queries_size_hint: usize, + media_queries_changed: bool, +} -impl StyleString { - pub fn as_str(&self) -> &str { - &self.0 +impl Stylesheet { + pub fn new() -> Self { + Self { + style: String::new(), + rendered_media_queries: String::new(), + + media_queries: HashMap::new(), + media_queries_size_hint: 0, + media_queries_changed: false, + } } - pub fn consume(self) -> String { - self.0 + + pub fn add(&mut self, rendered: &RenderedStyle) { + self.style.push_str(&rendered.style); + + self.media_queries_changed |= !rendered.media_queries.is_empty(); + + for (query, style) in &rendered.media_queries { + self.media_queries_size_hint += style.len() + query.len(); + match self.media_queries.entry(query.to_string()) { + Entry::Occupied(mut entry) => { + entry.get_mut().push('\n'); + entry.get_mut().push_str(&style); + } + Entry::Vacant(entry) => { + entry.insert(style.to_string()); + } + } + } + } + + pub fn render(&mut self) -> String { + if self.media_queries_changed { + debug!("re-rendering media queries"); + + self.rendered_media_queries = self.media_queries.iter().fold( + String::with_capacity(self.media_queries_size_hint), + |mut acc, (query, style)| { + acc.push_str(&format!("\n@media {}{{\n{}}}\n", query, style)); + acc + }, + ); + + self.media_queries_changed = false; + } + format!("{}{}", self.style, self.rendered_media_queries) } } diff --git a/fishnet/src/page.rs b/fishnet/src/page.rs index 78480ca..1e4cc72 100644 --- a/fishnet/src/page.rs +++ b/fishnet/src/page.rs @@ -9,6 +9,7 @@ use std::sync::Arc; use tokio::sync::Mutex; use tracing::{debug, debug_span, instrument, Instrument}; +use crate::css::Stylesheet; use crate::js::{self, ScriptType}; use crate::routes::APIRouter; @@ -32,7 +33,7 @@ pub struct BuiltPage { bundled_script: String, style_path: String, - stylesheet: String, + stylesheet: Stylesheet, // tasks that need to be awaited before serving content tasks: Vec>, @@ -73,7 +74,7 @@ impl BuiltPage { bundled_script, style_path, - stylesheet: String::new(), + stylesheet: Stylesheet::new(), tasks: Vec::new(), }; @@ -118,7 +119,7 @@ impl BuiltPage { } if let Some(component_globals) = render_context::global_store().get(id).await { if let Some(style) = &component_globals.style { - page.lock().await.stylesheet.push_str(style.as_str()); + page.lock().await.stylesheet.add(style); } for script in &component_globals.scripts { @@ -191,7 +192,7 @@ impl BuiltPage { ( [(header::CONTENT_TYPE, "text/css")], - page.stylesheet.clone(), + page.stylesheet.render(), ) } } diff --git a/fishnet/src/page/render_context.rs b/fishnet/src/page/render_context.rs index 82b9b1a..32edf97 100644 --- a/fishnet/src/page/render_context.rs +++ b/fishnet/src/page/render_context.rs @@ -35,7 +35,7 @@ impl ComponentStore { #[doc(hidden)] pub struct GlobalStoreEntry { pub scripts: Vec, - pub style: Option, + pub style: Option, } pub struct GlobalStore(Mutex>>); diff --git a/fishnet/tests/test_css.rs b/fishnet/tests/test_css.rs index 533aca5..b9a2ae2 100644 --- a/fishnet/tests/test_css.rs +++ b/fishnet/tests/test_css.rs @@ -39,8 +39,13 @@ fn test_css() { } }; + let mut stylesheet = fishnet::css::Stylesheet::new(); + stylesheet.add(&fragment.render("component")); + + println!("{}", stylesheet.render()); + assert_eq!( - fragment.render("component").as_str(), + stylesheet.render(), r".component { color: #f00000; display: inline-block; @@ -72,7 +77,6 @@ fn test_css() { .component #test { color: green; } -} -" +}" ); }