diff --git a/Cargo.lock b/Cargo.lock index 969eb27..f15b3b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -291,7 +291,7 @@ dependencies = [ [[package]] name = "attribute-derive" -version = "0.9.1" +version = "0.9.2" dependencies = [ "attribute-derive-macro", "derive-where", @@ -303,7 +303,7 @@ dependencies = [ [[package]] name = "attribute-derive-macro" -version = "0.9.1" +version = "0.9.2" dependencies = [ "collection_literals", "interpolator", @@ -1196,6 +1196,17 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "ghost" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0e085ded9f1267c32176b40921b9754c474f7dd96f7e808d4a982e48aa1e854" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.53", +] + [[package]] name = "gimli" version = "0.28.1" @@ -1448,9 +1459,9 @@ dependencies = [ "chrono", "derive_more 1.0.0-beta.6", "forr", + "ghost", "html", "html-escape", - "htmx", "htmx-macros", "insta", "serde", @@ -1465,7 +1476,9 @@ dependencies = [ "attribute-derive", "derive_more 1.0.0-beta.6", "forr", + "html-escape", "htmx-script", + "ident_case", "manyhow 0.9.0", "proc-macro-utils 0.10.0", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 63ac9c0..5504d7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,7 @@ repository = "https://github.com/ModProg/htmx" documentation = "https://docs.rs/htmx" [features] -default = ["axum", "actix-web"] -sorted_attributes = [] +# default = ["axum", "actix-web"] axum = ["dep:axum-core"] [dependencies] @@ -30,13 +29,10 @@ serde = "1.0.188" serde_json = "1.0.107" typed-builder = {git = "https://github.com/ModProg/rust-typed-builder", branch = "mutators"} chrono = "0.4.31" +ghost = "0.1.17" [dev-dependencies] insta = "1.31.0" -# Enables `sorted_attributes` for tests, to make assertions stable. -htmx = { path = ".", default-features = false, features = [ - "sorted_attributes", -] } serde = { version = "1.0.188", features = ["derive"] } [profile.dev.package.insta] diff --git a/example/Cargo.toml b/example/Cargo.toml index 4f375ab..d0dfdba 100644 --- a/example/Cargo.toml +++ b/example/Cargo.toml @@ -11,7 +11,7 @@ repository = "https://github.com/ModProg/htmx" documentation = "https://docs.rs/htmx" [features] -default = ["axum"] +default = ["axum", "tauri", "actix"] axum = ["htmx/axum", "dep:tokio", "dep:axum"] tauri = ["dep:tauri-runtime", "dep:tauri-runtime-wry", "dep:url"] actix = ["htmx/actix-web", "dep:actix-web"] diff --git a/example/axum.rs b/example/axum.rs index b4b0bb9..d0723e8 100644 --- a/example/axum.rs +++ b/example/axum.rs @@ -8,18 +8,17 @@ use std::error::Error; use axum::response::IntoResponse; use axum::routing::{get, post}; use axum::{Form, Router}; -use htmx::{html, HtmxSrc}; +use htmx::{html, HtmlPage, HtmxSrc}; async fn index() -> impl IntoResponse { html! { - - - -

"Axum Demo"

-
- - -
+ +

"Axum Demo"

+
+ + +
+ } } @@ -39,6 +38,7 @@ async fn main() -> Result<(), Box> { Router::new() .route("/", get(index)) .route("/greet", post(greet)) + .route("/htmx", get(HtmxSrc)) .into_make_service(), ) .await diff --git a/example/tauri.rs b/example/tauri.rs index 9b72193..855907c 100644 --- a/example/tauri.rs +++ b/example/tauri.rs @@ -8,7 +8,7 @@ use std::borrow::Cow; use std::error::Error; use std::fmt::Display; -use htmx::{html, Html, HtmxSrc}; +use htmx::{html, HtmxSrc}; use tauri_runtime::http::ResponseBuilder; use tauri_runtime::webview::{WebviewAttributes, WindowBuilder}; use tauri_runtime::window::PendingWindow; @@ -16,7 +16,7 @@ use tauri_runtime::Runtime; use tauri_runtime_wry::Wry; use url::Url; -fn index() -> Html { +fn index() -> String { html! {

"Tauri Demo"

@@ -25,14 +25,16 @@ fn index() -> Html { } + .into_string() } -fn greet(name: impl Display) -> Html { +fn greet(name: impl Display) -> String { html! { "Hello " {format!("{name}! ")} ":D" } + .into_string() } fn get_param<'a>(key: &str, url: &'a Url) -> Result, String> { @@ -61,7 +63,6 @@ fn main() -> Result<(), Box> { "/greet" => greet(get_param("name", &url)?), path => return Err(format!("Unknown path `{path}`").into()), } - .to_string() .into_bytes(), ) .unwrap()) diff --git a/htmx-macros/Cargo.toml b/htmx-macros/Cargo.toml index 4ceccf1..4bef215 100644 --- a/htmx-macros/Cargo.toml +++ b/htmx-macros/Cargo.toml @@ -19,7 +19,9 @@ proc-macro = true attribute-derive.path = "../../../Rust/attribute-derive" derive_more = { version = "1.0.0-beta.6", features = ["display"] } forr = "0.2.2" +html-escape = "0.2.13" htmx-script = { version = "0.1.0", path = "../htmx-script" } +ident_case = "1.0.1" manyhow = "0.9" proc-macro-utils = "0.10" proc-macro2 = "1.0.66" diff --git a/htmx-macros/src/component.rs b/htmx-macros/src/component.rs index d7388d8..dacf2e2 100644 --- a/htmx-macros/src/component.rs +++ b/htmx-macros/src/component.rs @@ -1,200 +1,426 @@ use attribute_derive::{FlagOrValue, FromAttr}; -use manyhow::{bail, ensure, error_message, Result}; -use quote::ToTokens; +use manyhow::{bail, ensure, Result}; +use proc_macro2::{Span, TokenStream}; +use quote::{format_ident, ToTokens, TokenStreamExt}; +use syn::punctuated::Punctuated; +use syn::token::{Brace, Paren}; use syn::{ - parse2, Attribute, Expr, FnArg, Ident, ItemFn, ItemStruct, Pat, PatIdent, PatTupleStruct, - PatType, Signature, Type, + AssocType, Attribute, Expr, FnArg, GenericArgument, Generics, Ident, Lifetime, Pat, PatIdent, + PatTupleStruct, PatType, PathArguments, ReturnType, Token, Type, TypeImplTrait, TypeParamBound, + Visibility, }; +use syn_derive::ToTokens; use crate::*; -pub fn component(_input: TokenStream, item: TokenStream) -> Result { - Ok(if let Ok(mut item) = parse2::(item.clone()) { - // Generate - // #[derive(typed_builder::TypedBuilder)] - // #[builder(crate_module_path=htmx::__private::typed_builder)] - // #[builder(build_method(into = Html))] - - // Set some defaults - for field in item.fields.iter_mut() { - type_attrs( - field.ident.as_ref().ok_or_else(|| { - error_message!("Only structs with named fields are supported") - })?, - &field.ty, - &mut field.attrs, - )?; +enum Arg { + Body(Ident), + Field(Field), +} + +#[derive(Debug)] +struct Field { + name: Ident, + ty: Type, + pat: Pat, + default: FlagOrValue, + default_type: Option, + doc_attrs: TokenStream, +} + +impl Field { + fn generic(&self) -> Ident { + Ident::new( + &ident_case::RenameRule::PascalCase.apply_to_field(self.name.to_string()), + self.name.span(), + ) + } + + fn field(&self) -> TokenStream { + let name = &self.name; + let generic = self.generic(); + quote!(#name: #generic) + } + + fn name(&self) -> &Ident { + &self.name + } + + fn unset(&self) -> TokenStream { + if let Some(default_type) = &self.default_type { + return quote!(#default_type); } - quote! { - #use ::htmx::Html; - #use ::htmx::__private::typed_builder::{self, TypedBuilder}; - #[derive(TypedBuilder)] - #[builder(crate_module_path = typed_builder)] - #[builder(build_method(into = Html))] - #item + if self.is_optional() { + if let Type::ImplTrait(TypeImplTrait { bounds, .. }) = &self.ty { + for t in bounds { + if let TypeParamBound::Trait(t) = t { + let t = t.path.segments.last().unwrap(); + if t.ident == "IntoIterator" { + if let PathArguments::AngleBracketed(t) = &t.arguments { + for t in &t.args { + if let GenericArgument::AssocType(t) = &t { + if t.ident == "Item" { + let t = &t.ty; + return quote!(::htmx::__private::Empty::<#t>); + } + } + } + } + } + } + } + } } - } else if let Ok(ItemFn { - attrs, - vis, - sig: - Signature { - ident, - generics, - inputs, - output, - .. - }, - block, - }) = parse2::(item.clone()) - { - // #[derive(typed_builder::TypedBuilder)] - // #[builder(crate_module_path=::typed_builder)] - // #[builder(build_method(into = Html))] - // struct MyFnComponent { - // a: bool, - // b: String, - // } - // - // impl Into for MyFnComponent { - // fn into(self) -> Html { - // let Self { a, b } = self; - // htmx! {crate - // - // } - // } - // } - ensure!(generics.params.is_empty(), "generics are not supported"); - let output = match output { - syn::ReturnType::Default => quote!(::htmx::Html), - syn::ReturnType::Type(_, ty) => ty.to_token_stream(), - }; + quote!(::htmx::__private::Unset) + } - let (fields, fieldpats): (Vec<_>, Vec<_>) = inputs - .into_iter() - .map(|arg| { - // hi - ensure!(let FnArg::Typed(PatType { mut attrs, pat, ty, .. }) = arg, - arg, "`self` is not supported"); - - let ident = match &*pat { - Pat::Ident(PatIdent { ident, .. }) => ident, - // On tuples with a single field, take its ident - Pat::TupleStruct(PatTupleStruct { elems, .. }) - if elems.len() == 1 && matches!(elems.first().unwrap(), Pat::Ident(_)) => - { - let Some(Pat::Ident(PatIdent { ident, .. })) = elems.first() else { - unreachable!("pat should contain a single ident") - }; - ident + fn unset_value(&self) -> TokenStream { + if let Some(default_type) = &self.default_type { + quote!(<#default_type>::default()) + } else { + self.unset() + } + } + + fn is_optional(&self) -> bool { + if let Type::Path(path) = &self.ty { + path.path.is_ident("bool") + || path.path.segments.len() == 1 + && path.path.segments.first().unwrap().ident == "Option" + || !self.default.is_none() + } else { + !self.default.is_none() + } + } + + fn destructure(&self) -> TokenStream { + let name = &self.name; + let pat = &self.pat; + match &self.default { + FlagOrValue::Value(default) => quote!(let #pat = #name.get_or_else(|| #default);), + _ if self.is_impl_trait() && self.is_optional() => quote! {}, + _ if self.is_impl_trait() => quote!(let ::htmx::__private::Set(#name) = #name;), + _ if self.is_optional() => quote!(let #pat = #name.get_or_default();), + _ => quote!(let ::htmx::__private::Set(#name) = #name;), + } + } + + fn get_generics(&self, base: &Ident) -> Option { + if let Type::ImplTrait(ty) = &self.ty { + let mut tokens = TokenStream::new(); + desugar_impl(&mut tokens, ty.clone(), base); + Some(tokens) + } else { + None + } + } + + fn is_impl_trait(&self) -> bool { + matches!(self.ty, Type::ImplTrait(_)) + } +} + +fn desugar_impl(tokens: &mut TokenStream, ty: TypeImplTrait, base: &Ident) { + let mut count = 0; + let mut bounds = ty.bounds; + for bound in &mut bounds { + if let TypeParamBound::Trait(bound) = bound { + for segment in &mut bound.path.segments { + if let PathArguments::AngleBracketed(arguments) = &mut segment.arguments { + for argument in &mut arguments.args { + if let GenericArgument::Type(ty) + | GenericArgument::AssocType(AssocType { ty, .. }) = argument + { + if let Type::ImplTrait(tr) = ty { + let gen_ident = format_ident!("{base}_{count}"); + count += 1; + desugar_impl(tokens, tr.clone(), &gen_ident); + tokens.extend(quote!(,)); + *ty = parse_quote!(#gen_ident); + }; + } } - pat => bail!(pat, "only named arguments and new type patterns are allowed"; - help = "use `ident @ {}`", pat.into_token_stream();), + } + } + } + } + tokens.extend(quote!(#base: #bounds)); +} + +impl TryFrom for Arg { + type Error = manyhow::Error; + + fn try_from(arg: FnArg) -> std::result::Result { + // hi + ensure!(let FnArg::Typed(PatType { mut attrs, pat, ty, .. }) = arg, + arg, "`self` is not supported"); + + let ident = match &*pat { + Pat::Ident(PatIdent { ident, .. }) => ident, + // On tuples with a single field, take its ident + Pat::TupleStruct(PatTupleStruct { elems, .. }) + if elems.len() == 1 && matches!(elems.first().unwrap(), Pat::Ident(_)) => + { + let Some(Pat::Ident(PatIdent { ident, .. })) = elems.first() else { + unreachable!("pat should contain a single ident") }; + ident + } + pat => bail!( + pat, "only named arguments and new type patterns are allowed"; + help = "use `ident @ {}`", pat.into_token_stream(); + ), + }; + + if ident == "body" { + return Ok(Arg::Body(ident.clone())) + } + + let DefaultAttr(mut default) = DefaultAttr::remove_attributes(&mut attrs)?; + let DefaultType(default_type) = DefaultType::remove_attributes(&mut attrs)?; + // let ChildrenAttr(children) = ChildrenAttr::remove_attributes(attrs)?; - type_attrs(ident, &ty, &mut attrs)?; + if default_type.is_some() && default.is_none() { + default = FlagOrValue::Flag; + } - Ok((quote!(#(#attrs)* pub #ident: #ty,), quote!(#ident: #pat,))) - }) - .collect::>>()? + let doc_attrs = attrs .into_iter() - .unzip(); - - // #attrs #vis struct - quote! { - #use ::htmx::__private::typed_builder::{self, TypedBuilder}; - #[derive(TypedBuilder)] - #[builder(crate_module_path=typed_builder)] - #[builder(build_method(into=#output))] - #(#attrs)* - #vis struct #ident { - #(#fields)* + .filter(|a| a.path().is_ident("doc")) + .map(ToTokens::into_token_stream) + .collect(); + + Ok(Arg::Field(Field { + name: ident.clone(), + pat: *pat, + ty: *ty, + default, + default_type, + doc_attrs, + })) + // Ok((quote!(#(#attrs)* pub #ident: #ty,), quote!(#ident: #pat,))) + } +} + +#[derive(syn_derive::Parse, ToTokens)] +pub struct Component { + #[parse(Attribute::parse_outer)] + #[to_tokens(|ts, t| ts.append_all(t))] + attrs: Vec, + vis: Visibility, + fn_token: Token![fn], + name: Ident, + generics: Generics, + #[syn(parenthesized)] + paren_token: Paren, + #[syn(in = paren_token)] + #[parse(Punctuated::parse_terminated)] + inputs: Punctuated, + output: ReturnType, + #[syn(braced)] + brace_token: Brace, + #[syn(in = brace_token)] + body: TokenStream, +} + +pub fn component( + _input: TokenStream, + Component { + attrs, + vis, + name: struct_name, + generics, + inputs, + output, + body: fn_body, + .. + }: Component, +) -> Result { + ensure!(generics.params.is_empty(), "generics are not supported"); + if let ReturnType::Type(_, t) = &output { + if let Type::Tuple(t) = &**t { + if !t.elems.is_empty() { + bail!(output, "expected `()` return type"); } + } else { + bail!(output, "expected `()` return type"); + } + } + + let (body, args) = inputs.into_iter().map(Arg::try_from).try_fold( + Default::default(), + |mut acc, arg| -> Result<(Option, Vec)> { + match arg? { + Arg::Body(body) => { + ensure!(acc.0.is_none(), body, "multiple `body` arguments"); + acc.0 = Some(body); + } + Arg::Field(field) => acc.1.push(field), + }; + Ok(acc) + }, + )?; + + let body = body.unwrap_or_else(|| Ident::new("body", Span::call_site())); + + let html_lt = Lifetime::new("'html", Span::call_site()); + + let fields = args.iter().map(Field::field); + let generics = args.iter().map(Field::generic); + let unsets_types: Vec<_> = args.iter().map(Field::unset).collect(); + let unset_values: Vec<_> = args.iter().map(Field::unset_value).collect(); + let field_names: Vec<_> = args.iter().map(Field::name).collect(); + + let optional_gens = args + .iter() + .filter(|&f| (f.is_optional() && !f.is_impl_trait())) + .map(|f| { + let g = f.generic(); + let ty = &f.ty; + quote!(#g: ::htmx::__private::Settable<#ty>) + }) + .chain(args.iter().filter_map(|f| f.get_generics(&f.generic()))); - impl From<#ident> for #output { - #[allow(non_shorthand_field_patterns)] - fn from(#ident{ #(#fieldpats)* }: #ident) -> #output #block + let mandatory_gens = args.iter().map(|f| { + if f.is_optional() { + f.generic().into_token_stream() + } else if f.is_impl_trait() { + let gen = f.generic(); + quote!(::htmx::__private::Set<#gen>) + } else { + let ty = &f.ty; + quote!(::htmx::__private::Set<#ty>) + } + }); + + let field_destructure = args.iter().map(Field::destructure); + + let mut setters = vec![]; + for i in 0..args.len() { + let mut impl_gens = vec![]; + let mut unset_gens = vec![]; + let mut set_gens = vec![]; + let mut destructure = vec![]; + let mut structure = vec![]; + + let field @ Field { + name: field_name, + doc_attrs, + .. + } = &args[i]; + let gen = field.generic(); + + let mut fn_gen = None; + + for (idx, field @ Field { ty, name, .. }) in args.iter().enumerate() { + let generic = field.generic().to_token_stream(); + if idx == i { + unset_gens.push(field.unset()); + destructure.push(quote!(#name: _)); + if let Some(bounds) = field.get_generics(&gen) { + fn_gen = Some(quote!(#bounds)); + set_gens.push(quote!(::htmx::__private::Set<#generic>)); + structure.push(quote!(#name: ::htmx::__private::Set(#name))); + } else { + fn_gen = Some(quote!(#gen: Into<#ty>)); + set_gens.push(quote!(::htmx::__private::Set<#ty>)); + structure.push(quote!(#name: ::htmx::__private::Set(#name.into()))); + }; + } else { + impl_gens.push(quote!(#generic)); + unset_gens.push(quote!(#generic)); + set_gens.push(quote!(#generic)); + destructure.push(quote!(#name)); + structure.push(quote!(#name)); } } - } else { - bail!(item, "only functions and structs are supported") + + let already_set_msg = format!("{field_name} was alredy set"); + let already_set_ty = format_ident!("{field_name}_was_alredy_set"); + + let extra_gen = field.is_impl_trait().then_some(&gen).into_iter(); + + setters.push(quote! { + impl<#html_lt, #(#impl_gens),*> #struct_name<#html_lt, #(#unset_gens),*> { + #doc_attrs + pub fn #field_name<#fn_gen>(self, #field_name: #gen) + -> #struct_name<#html_lt, #(#set_gens),*> { + let Self { + html, + #(#destructure),* + } = self; + #struct_name { + html, + #(#structure),* + } + } + } + + #[allow(non_camel_case_types)] + pub struct #already_set_ty; + + impl<#html_lt, #(#extra_gen,)* #(#impl_gens),*> #struct_name<#html_lt, #(#set_gens),*> { + #[doc(hidden)] + #[deprecated = #already_set_msg] + #[allow(unused)] + pub fn #field_name<__Gen>( + self, + #field_name: __Gen, _: #already_set_ty + ) -> Self { + self + } + } + }); + } + + // #attrs #vis struct + Ok(quote! { + #use ::htmx::__private::{Set}; + + #(#attrs)* + #[must_use = "call body or close"] + #vis struct #struct_name<#html_lt, #(#generics),*> { + html: ::core::marker::PhantomData<&#html_lt ()>, + #(#fields),* + } + const _: () = { + use ::core::default::Default as _; + impl<#html_lt> #struct_name<#html_lt, #(#unsets_types),*> { + pub fn new(_: &mut ::htmx::Html) -> Self { + Self { + html: ::core::marker::PhantomData, + #(#field_names: #unset_values),* + } + } + } + + #(#setters)* + + impl<#html_lt, #(#optional_gens),*> #struct_name<#html_lt, #(#mandatory_gens),*> { + pub fn body(self, #body: impl ::htmx::IntoHtml + #html_lt) -> impl ::htmx::IntoHtml + #html_lt { + let Self { + html: _, + #(#field_names),* + } = self; + + #(#field_destructure;)* + + + ::htmx::Fragment(move |__html: &mut ::htmx::Html|(||{#fn_body})().into_html(__html)) + } + + pub fn close(self) -> impl ::htmx::IntoHtml + #html_lt { + self.body(::htmx::Fragment::EMPTY) + } + } + }; }) } -// todo derive macro -// #[component] -// fn MyFnComponent(a: bool, b: String) -> Html { -// htmx! {crate -// -// } -// } -// -// // Generates -// -// #[derive(typed_builder::TypedBuilder)] -// #[builder(crate_module_path=::typed_builder)] -// #[builder(build_method(into = Html))] -// struct MyFnComponent { -// a: bool, -// b: String, -// } -// -// impl Into for MyFnComponent { -// fn into(self) -> Html { -// let Self { a, b } = self; -// htmx! {crate -// -// } -// } -// } -// -// // Using only struct -// #[derive(Component)] -// struct MyStructComponent { -// a: bool, -// b: String, -// } -// impl Into for MyStructComponent { -// fn into(self) -> Html { -// let Self { a, b } = self; -// htmx! {crate -// -// } -// } -// } -// -// #[derive(FromAttr)] #[attribute(ident = default)] struct DefaultAttr(FlagOrValue); #[derive(FromAttr)] -#[attribute(ident = default)] -struct ChildrenAttr(bool); - -fn type_attrs(name: &Ident, ty: &Type, attrs: &mut Vec) -> Result<()> { - let DefaultAttr(default) = DefaultAttr::remove_attributes(attrs)?; - let ChildrenAttr(children) = ChildrenAttr::remove_attributes(attrs)?; - if let Type::Path(path) = ty { - // TODO strip Option - if default.is_flag() || path.path.is_ident("bool") || path.path.is_ident("Option") { - attrs.push(parse_quote!(#[builder(default)])) - } - if let Some(default) = default.as_value() { - attrs.push(parse_quote!(#[builder(default = #default)])) - } - if children - || path.path.is_ident("Children") - || path.path == parse_quote!(htmx::Children) - || path.path == parse_quote!(::htmx::Children) - { - attrs.push(parse_quote! {#[builder(via_mutators, mutators( - pub fn child(&mut self, __child: impl ::htmx::ToHtml) { - self.#name.push(__child); - } - ))]}); - attrs.push(parse_quote!(#[allow(missing_docs)])); - } - } - attrs.push(parse_quote!(#[builder(setter(into))])); - Ok(()) -} +#[attribute(ident = default_type)] +struct DefaultType(Option); diff --git a/htmx-macros/src/htmx/html.rs b/htmx-macros/src/htmx/html.rs index 29f6b40..13cd079 100644 --- a/htmx-macros/src/htmx/html.rs +++ b/htmx-macros/src/htmx/html.rs @@ -1,29 +1,21 @@ use htmx_script::{Script, ToJs}; -use manyhow::{ensure, ErrorMessage, Result}; +use manyhow::{ensure, Error, ErrorMessage, Result}; use proc_macro2::TokenStream; -use proc_macro_utils::TokenStream2Ext; use quote::ToTokens; use rstml::atoms::{CloseTag, OpenTag}; use rstml::node::{ AttributeValueExpr, KeyedAttribute, KeyedAttributeValue, NodeAttribute, NodeBlock, NodeElement, - NodeName, + NodeFragment, NodeName, }; use rstml::recoverable::Recoverable; use syn::spanned::Spanned; use syn::{parse2, Expr, ExprLit, ExprPath, Lit, LitStr, Stmt}; use super::special_components::{Node, Special}; +use super::try_into_iter; use crate::*; pub fn html(input: TokenStream) -> Result { - let mut parser = input.clone().parser(); - let (input, expr) = - if let ( Some(expr), Some(_)) = (parser.next_expression(), parser.next_tt_fat_arrow()) { - (parser.into_token_stream(), quote!(&mut #expr)) - } else { - (input, quote!(::htmx::Html::new())) - }; - let nodes = rstml::Parser::new( rstml::ParserConfig::new() .recover_block(true) @@ -32,19 +24,161 @@ pub fn html(input: TokenStream) -> Result { .raw_text_elements(["script"].into()), ) // TODO parse_recoverable - .parse_simple(input)? - .into_iter() - .map(expand_node) - .collect::()?; + .parse_simple(input)?; - Ok(quote! { - { - use ::htmx::native::*; - let mut __html = #expr; - #nodes - __html + super::expand_nodes(nodes) +} + +impl TryFrom for super::Node { + type Error = Error; + + fn try_from(value: Node) -> std::result::Result { + match value { + Node::Comment(comment) => bail!(comment, "html comments are not supported"), + Node::Doctype(doc_type) => bail!(doc_type, "doc typ is set automatically"), + Node::Fragment(NodeFragment { tag_open, .. }) => bail!(tag_open, "missing tag name"), + Node::Element(element) => Ok(super::Node::Element(element.try_into()?)), + Node::Block(block) => Ok(super::Node::Block(block.into_token_stream())), + Node::Text(text) => Ok(super::Node::String(text.value)), + Node::RawText(text) => bail!( + text.into_token_stream().into_iter().next(), + "expected `<`, `{{` or `\"`" + ), + Node::Custom(special) => special.try_into(), } - }) + } +} + +impl TryFrom> for super::Element { + type Error = Error; + + fn try_from(value: NodeElement) -> std::result::Result { + let NodeElement { + open_tag, + children, + close_tag, + } = value; + Ok(super::Element { + close_tag: close_tag.and_then(|ct| match ct.name { + NodeName::Path(p) if !ct.name.is_wildcard() => Some(p.into_token_stream()), + _ => None, + }), + attributes: try_into_iter(open_tag.attributes)?, + body: if !children.is_empty() + && matches!(&open_tag.name, NodeName::Path(p) if p.path.is_ident("script")) + { + let Some(Node::RawText(script)) = children.first() else { + unreachable!("script always raw text") + }; + let script = script.into_token_stream(); + if let Ok(script) = parse2::(script.clone()) { + super::ElementBody::Script(super::ScriptBody::String(script)) + } else if let Ok(block) = + parse2::>(script.clone()).map(Recoverable::inner) + { + super::ElementBody::Script(super::ScriptBody::Expr(block.into_token_stream())) + } else { + let script: Script = parse2(script)?; + let script = script.to_java_script(); + // quote!(__html.body(#script);) + super::ElementBody::Script(super::ScriptBody::Expr(script.into_token_stream())) + } + } else { + super::ElementBody::Children(try_into_iter(children)?) + }, + open_tag: open_tag.name.try_into()?, + }) + } +} + +fn string_from_block(block: &syn::Block) -> Option<&LitStr> { + if let [ + Stmt::Expr( + Expr::Lit(ExprLit { + lit: Lit::Str(lit), .. + }), + None, + ), + ] = &block.stmts[..] + { + Some(lit) + } else { + None + } +} + +impl TryFrom for super::OpenTag { + type Error = Error; + + fn try_from(value: NodeName) -> std::result::Result { + Ok(match value { + NodeName::Path(path) => super::OpenTag::Path(path.into_token_stream()), + name @ NodeName::Punctuated(_) => { + super::OpenTag::from_str(name.to_string(), name.span())? + } + NodeName::Block(name) => { + if let Some(name) = string_from_block(&name) { + super::OpenTag::from_str(name.value(), name.span())? + } else { + super::OpenTag::Expr(name.into_token_stream()) + } + } + }) + } +} + +impl TryFrom for super::Attribute { + type Error = Error; + + fn try_from(value: NodeAttribute) -> std::result::Result { + Ok(match value { + NodeAttribute::Block(name) => super::Attribute { + key: if let Some(name) = name.try_block().and_then(string_from_block) { + super::AttributeKey::from_str(name.value(), name.span())? + } else { + super::AttributeKey::Expr(name.into_token_stream()) + }, + value: None, + }, + NodeAttribute::Attribute(attribute) => super::Attribute { + value: attribute.value().map(ToTokens::into_token_stream), + key: attribute.key.try_into()?, + }, + }) + } +} + +impl TryFrom for super::AttributeKey { + type Error = Error; + + fn try_from(value: NodeName) -> std::result::Result { + Ok(match value { + NodeName::Path(p) if p.path.get_ident().is_some() => { + super::AttributeKey::Fn(p.into_token_stream()) + } + NodeName::Path(p) if p.path.segments.first().is_some_and(|hx| hx.ident == "hx") => { + let sident = p + .path + .segments + .iter() + .map(|i| i.ident.to_string().replace('_', "-")) + // hx::swap::oob + .collect::>() + .join("-"); + super::AttributeKey::from_str(sident, p.span())? + } + key @ (NodeName::Punctuated(_) | NodeName::Path(_)) => { + super::AttributeKey::from_str(key.to_string(), key.span())? + } + NodeName::Block(block) => { + if let Some(key) = string_from_block(&block) { + super::AttributeKey::from_str(key.value(), key.span())? + } else { + super::AttributeKey::Expr(block.into_token_stream()) + } + } + }) + } } pub fn expand_node(node: Node) -> Result { @@ -61,7 +195,7 @@ pub fn expand_node(node: Node) -> Result { .. }) => { let script = name.to_string() == "script"; - let (name, custom) = name_to_struct(name)?; + let (name, node_type) = name_to_struct(name)?; let attributes = attributes .into_iter() .map(|attribute| match attribute { @@ -72,9 +206,11 @@ pub fn expand_node(node: Node) -> Result { }) => match possible_value { KeyedAttributeValue::Binding(_) => todo!("{}", line!()), KeyedAttributeValue::Value(AttributeValueExpr { value, .. }) => { - attribute_key_to_fn(key, value, custom) + attribute_key_to_fn(key, value, matches!(node_type, NodeType::Custom)) + } + KeyedAttributeValue::None => { + attribute_key_to_fn(key, true, matches!(node_type, NodeType::Custom)) } - KeyedAttributeValue::None => attribute_key_to_fn(key, true, custom), }, }) .collect::>>()?; @@ -87,21 +223,33 @@ pub fn expand_node(node: Node) -> Result { }; let script = script.into_token_stream(); if let Ok(script) = parse2::(script.clone()) { + // quote!(__html.body(#script);) quote!(::htmx::ToScript::to_script(&#script, &mut __html);) } else if let Ok(block) = parse2::>(script.clone()).map(Recoverable::inner) { - quote!(::htmx::ToScript::to_script(&{#[allow(unused_braces)] #block}, &mut __html);) + // quote!(__html.body({#[allow(unused_braces)] #block});) + quote!(::htmx::ToScript::to_script(&{# [allow(unused_braces)] #block}, &mut __html);) } else { let script: Script = parse2(script)?; let script = script.to_java_script(); - quote!(::htmx::ToScript::to_script(#script, &mut __html);) + // quote!(__html.body(#script);) + quote!(::htmx::ToScript::to_script(&#script, &mut __html);) } } else { expand_nodes(children)? }; - let body = (!children.is_empty()).then(|| quote!(let mut __html = __html.body(|mut __html| {#children});)); - let main = quote!({let mut __html = #name #(.#attributes)*; #body;}); + let close_arg = if matches!(node_type, NodeType::Component) { + quote!(&mut __html) + } else { + quote!() + }; + let body = if children.is_empty() { + quote!(.close(#close_arg)) + } else { + quote!(.body(::htmx::Fragment(|mut __html: &mut ::htmx::Html| {#children}), #close_arg)) + }; + let main = quote!({{let mut __html = #name #(.#attributes)*; __html}#body;}); match close_tag { Some(CloseTag { @@ -115,7 +263,7 @@ pub fn expand_node(node: Node) -> Result { } } Node::Block(_) | Node::Text(_) => { - quote!(::htmx::ToHtml::to_html(&{#[allow(unused_braces)] #node}, &mut __html);) + quote!(::htmx::IntoHtml::into_html({#[allow(unused_braces)] #node}, &mut __html);) } Node::RawText(_) => todo!("{}", line!()), Node::Custom(c) => c.expand_node()?, @@ -137,14 +285,28 @@ pub fn ensure_tag_name(name: String, span: impl ToTokens) -> Result Result<(TokenStream, bool)> { +enum NodeType { + Native, + Component, + Custom, +} + +fn name_to_struct(name: NodeName) -> Result<(TokenStream, NodeType)> { match name { - NodeName::Path(path) => Ok((quote!(#path::new(&mut __html)), false)), + NodeName::Path(path) + if path + .path + .get_ident() + .is_some_and(|i| !i.to_string().contains(char::is_uppercase)) => + { + Ok((quote!(#path::new(&mut __html)), NodeType::Native)) + } + NodeName::Path(path) => Ok((quote!(#path::new()), NodeType::Component)), name @ NodeName::Punctuated(_) => { let name = ensure_tag_name(name.to_string(), name)?; Ok(( quote!(::htmx::CustomElement::new_unchecked(&mut __html, #name)), - true, + NodeType::Custom, )) } // This {...} @@ -162,12 +324,12 @@ fn name_to_struct(name: NodeName) -> Result<(TokenStream, bool)> { let name = ensure_tag_name(name.value(), name)?; Ok(( quote!(::htmx::CustomElement::new_unchecked(&mut __html, #name)), - true, + NodeType::Custom, )) } else { Ok(( quote!(::htmx::CustomElement::new(&mut __html, #name)), - true, + NodeType::Custom, )) } } diff --git a/htmx-macros/src/htmx/mod.rs b/htmx-macros/src/htmx/mod.rs index 3785f42..d6a370a 100644 --- a/htmx-macros/src/htmx/mod.rs +++ b/htmx-macros/src/htmx/mod.rs @@ -1,10 +1,16 @@ -mod html; +#![allow(unused)] +pub mod html; mod special_components; -mod rusty; +pub mod rusty; -pub use html::html; -pub use rusty::html as rtml; +use html_escape::{encode_safe, encode_script}; +use manyhow::ensure; +use proc_macro2::{Literal, Span}; +use syn::spanned::Spanned; +use syn::LitStr; + +use super::*; // pub fn html(input: TokenStream) -> Result { // if input.is_empty() { @@ -26,3 +32,301 @@ pub use rusty::html as rtml; // rusty::html(input.collect()) // } // } + +fn try_into_iter( + input: impl IntoIterator>, +) -> Result> { + input.into_iter().map(TryInto::try_into).collect() +} + +fn expand_nodes( + nodes: impl IntoIterator>, +) -> Result { + let nodes = nodes + .into_iter() + .map(TryInto::try_into) + .collect::>>()?; + Ok(quote! { + ::htmx::Fragment(move |mut __html: &mut ::htmx::Html| { + #[allow(unused_braces)] + { + use ::htmx::native::*; + use ::htmx::IntoHtml as _; + #(#nodes)* + }; + }) + }) +} + +enum Node { + String(LitStr), + Block(TokenStream), + If(If), + For(For), + While(While), + FunctionCall(FunctionCall), + Element(Element), +} + +impl ToTokens for Node { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Node::String(lit) => { + let value = lit.value(); + let value = encode_safe(&value); + let mut value = Literal::string(&value); + value.set_span(lit.span()); + quote!(::htmx::IntoHtml::into_html(::htmx::RawSrc::new(#value), &mut __html);).to_tokens(tokens) + } + Node::Block(block) => { + quote!(::htmx::IntoHtml::into_html({#[allow(unused_braces)] {#block}}, &mut __html);).to_tokens(tokens) + } + Node::If(if_) => if_.to_tokens(tokens), + Node::For(for_) => for_.to_tokens(tokens), + Node::While(while_) => while_.to_tokens(tokens), + Node::FunctionCall(call) => call.to_tokens(tokens), + Node::Element(element) => element.to_tokens(tokens) + } + } +} + +struct If { + condition: TokenStream, + then_branch: Vec, + else_branch: ElseBranch, +} + +impl ToTokens for If { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Self { + condition, + then_branch, + else_branch, + } = self; + quote! { + if #condition { + #(#then_branch)* + } #else_branch + } + .to_tokens(tokens) + } +} + +enum ElseBranch { + None, + Else(Vec), + ElseIf(Box), +} + +impl ToTokens for ElseBranch { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + ElseBranch::None => {} + ElseBranch::Else(nodes) => quote!(else {#(#nodes)*}).to_tokens(tokens), + ElseBranch::ElseIf(if_) => quote!(else #if_).to_tokens(tokens), + } + } +} + +struct For { + pat: TokenStream, + expr: TokenStream, + body: Vec, +} + +impl ToTokens for For { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Self { pat, expr, body } = self; + quote! { + for #pat in #expr { + #(#body)* + } + } + .to_tokens(tokens) + } +} + +struct While { + expr: TokenStream, + body: Vec, +} + +impl ToTokens for While { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Self { expr, body } = self; + quote! { + while #expr { + #(#body)* + } + } + .to_tokens(tokens) + } +} + +struct FunctionCall { + function: TokenStream, + args: Vec, +} + +impl ToTokens for FunctionCall { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Self { function, args } = self; + quote!(::htmx::IntoHtml::into_html(#function(#(Into::into(#args),)*), &mut __html);) + .to_tokens(tokens) + } +} + +struct Element { + open_tag: OpenTag, + close_tag: Option, + attributes: Vec, + body: ElementBody, +} + +impl ToTokens for Element { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Self { + open_tag, + close_tag, + attributes, + body, + } = self; + + let close_tag = close_tag.iter(); + + let mut attributes = attributes.clone(); + + if !matches!(open_tag, OpenTag::Path(_)) { + for attribute in &mut attributes { + if let AttributeKey::Fn(name) = &attribute.key { + attribute.key = AttributeKey::from_str(name.to_string(), name.span()) + .expect("idents should be valid attribute keys") + } + } + }; + + quote! { + {{ + #( use ::htmx::__private::Unused; #close_tag::unused(); )* + #open_tag #(#attributes)* #body + }.into_html(&mut __html)} + } + .to_tokens(tokens) + } +} + +enum OpenTag { + Path(TokenStream), + String(String, Span), + Expr(TokenStream), +} + +impl OpenTag { + fn from_str(name: String, span: Span) -> Result { + ensure!( + name.to_ascii_lowercase().chars() + .all(|c| matches!(c, '-' | '.' | '0'..='9' | '_' | 'a'..='z' | '\u{B7}' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{203F}'..='\u{2040}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')), + span, + "invalid tag name `{name}`, https://html.spec.whatwg.org/multipage/custom-elements.html#prod-potentialcustomelementname" + // TODO similar function but with css error: https://drafts.csswg.org/css-syntax-3/#non-ascii-ident-code-point + ); + Ok(OpenTag::String(name, span)) + } +} + +impl ToTokens for OpenTag { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + OpenTag::Path(path) => quote!(#path::new(&mut __html)), + OpenTag::String(name, span) => { + let name = quote_spanned!(*span=> #name); + quote!(::htmx::CustomElement::new_unchecked(&mut __html, #name)) + } + OpenTag::Expr(name) => quote!(quote!(::htmx::CustomElement::new(&mut __html, #name)),), + } + .to_tokens(tokens) + } +} + +#[derive(Clone)] +struct Attribute { + key: AttributeKey, + // TODO value encoding + value: Option, +} + +impl ToTokens for Attribute { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Self { key, value } = self; + let value = value.clone().unwrap_or_else(|| quote!(true)); + match key { + AttributeKey::Fn(fun) => quote!(.#fun(#value)), + AttributeKey::String(key, span) => { + let key = quote_spanned!(*span => #key); + quote!(.custom_attr_unchecked(#key, #value)) + } + AttributeKey::Expr(key) => quote!(.custom_attr(#key, #value)), + } + .to_tokens(tokens); + } +} + +#[derive(Clone)] +enum AttributeKey { + Fn(TokenStream), + String(String, Span), + Expr(TokenStream), +} + +impl AttributeKey { + fn from_str(value: String, span: Span) -> Result { + ensure!( + !value.to_string().chars().any(|c| c.is_whitespace() + || c.is_control() + || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), + span, + "invalid key `{value}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0" + ); + Ok(AttributeKey::String(value, span)) + } +} + +enum ElementBody { + Script(ScriptBody), + Children(Vec), +} + +impl ToTokens for ElementBody { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + ElementBody::Script(script) => quote!(.body(#script)), + ElementBody::Children(children) if children.is_empty() => { + quote!(.close()) + } + ElementBody::Children(children) => { + quote!(.body(::htmx::Fragment(|mut __html: &mut ::htmx::Html| {#(#children)*}))) + } + } + .to_tokens(tokens) + } +} + +enum ScriptBody { + String(LitStr), + Expr(TokenStream), +} + +impl ToTokens for ScriptBody { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + ScriptBody::String(lit) => { + let value = lit.value(); + let value = encode_script(&value); + let mut value = Literal::string(&value); + value.set_span(lit.span()); + quote!(RawSrc(#value)).to_tokens(tokens) + } + ScriptBody::Expr(expr) => expr.to_tokens(tokens), + } + } +} diff --git a/htmx-macros/src/htmx/rusty.rs b/htmx-macros/src/htmx/rusty.rs index 7af5d38..de92eab 100644 --- a/htmx-macros/src/htmx/rusty.rs +++ b/htmx-macros/src/htmx/rusty.rs @@ -2,6 +2,7 @@ use std::mem; use manyhow::{bail, ensure, Result}; use proc_macro2::{TokenStream, TokenTree}; +use proc_macro_utils::TokenStream2Ext; use quote::{format_ident, ToTokens}; use syn::ext::IdentExt; use syn::parse::discouraged::Speculative; @@ -14,17 +15,14 @@ use syn_derive::{Parse, ToTokens}; use super::html::ensure_tag_name; use crate::*; -pub fn html(input: TokenStream) -> Result { +pub fn rtml(input: TokenStream) -> Result { let nodes = expand_nodes(Punctuated::::parse_terminated.parse2(input)?); Ok(quote! { - #use ::htmx::{ToHtml, Html, IntoHtmlElements}; - { + ::htmx::Fragment(|mut __html: &mut ::htmx::Html| { use ::htmx::native::*; - let mut __html = Html::new(); #(#nodes)* - __html - } + }) }) } @@ -63,8 +61,8 @@ impl Node { quote!(::htmx::ToHtml::to_html(&#lit, &mut __html);) } Node::Block(block) => { - quote!(::htmx::ToHtml::to_html(&#block, &mut __html);) - }, + quote!(::htmx::IntoHtml::into_html({#[allow(unused_braces)] #block}, &mut __html);) + } Node::Element(element) => element.expand(), Node::If(node) => node.expand(), Node::For(node) => node.expand(), @@ -387,6 +385,7 @@ struct Element { impl Element { fn expand(self) -> TokenStream { let mut attrs = self.attrs.unwrap_or_default(); + let mut close_arg = quote!(); let name = match self.path { ElementName::String(name) => { quote!(::htmx::CustomElement::new_unchecked(&mut __html, #name);) @@ -396,7 +395,17 @@ impl Element { attrs.attrs.push(Attr::Classes(classes)); quote!(div::new(&mut __html)) } - ElementName::Path(path) => quote!(#path::new(&mut __html);), + ElementName::Path(path) + if path + .get_ident() + .is_some_and(|i| !i.to_string().contains(char::is_uppercase)) => + { + quote!(#path::new(&mut __html);) + } + ElementName::Path(path) => { + close_arg = quote!(&mut __html); + quote!(#path::new();) + } }; let mut children = expand_nodes( @@ -410,13 +419,16 @@ impl Element { let attrs = attrs.attrs.into_iter().map(Attr::expand); - let body = children.peek().is_some().then(|| quote!(__html.body(|mut __html| {#(#children)*}))); + let body = children + .peek() + .is_some() + .then(|| quote!(.body(::htmx::Fragment(|mut __html: &mut ::htmx::Html| {#(#children)*}), #close_arg))).unwrap_or_else(|| quote!(.close(#close_arg))); - quote!({ - let mut __html = #name - #(__html #attrs;)* - #body; - }) + quote!({{ + let mut __html = #name; + #(let __html = __html #attrs;)* + __html + }#body;}) } } diff --git a/htmx-macros/src/htmx/special_components.rs b/htmx-macros/src/htmx/special_components.rs index 4ed6657..25ad02c 100644 --- a/htmx-macros/src/htmx/special_components.rs +++ b/htmx-macros/src/htmx/special_components.rs @@ -74,6 +74,37 @@ pub enum Special { FunctionCall(FunctionCall), } +fn map_vec(value: Vec) -> Result> { + value.into_iter().map(super::Node::try_from).collect() +} + +impl TryFrom for super::Node { + type Error = manyhow::Error; + + fn try_from(value: Special) -> std::result::Result { + Ok(match value { + Special::If(if_) => super::Node::If(if_.try_into()?), + Special::For(For { + pat, expr, body, .. + }) => super::Node::For(super::For { + pat: pat.into_token_stream(), + expr: expr.into_token_stream(), + body: map_vec(body)?, + }), + Special::While(While { expr, body, .. }) => super::Node::While(super::While { + expr: expr.into_token_stream(), + body: map_vec(body)?, + }), + Special::FunctionCall(FunctionCall { function, args, .. }) => { + super::Node::FunctionCall(super::FunctionCall { + function: function.into_token_stream(), + args: args.into_iter().map(ToTokens::into_token_stream).collect(), + }) + } + }) + } +} + impl Special { pub(crate) fn expand_node(self) -> Result { match self { @@ -123,6 +154,31 @@ pub struct If { pub else_branch: ElseBranch, } +impl TryFrom for super::If { + type Error = manyhow::Error; + + fn try_from( + If { + condition, + then_branch, + else_branch, + .. + }: If, + ) -> std::result::Result { + Ok(super::If { + condition: condition.into_token_stream(), + then_branch: map_vec(then_branch)?, + else_branch: match else_branch { + ElseBranch::None => super::ElseBranch::None, + ElseBranch::Else { body, .. } => super::ElseBranch::Else(map_vec(body)?), + ElseBranch::ElseIf { body, .. } => { + super::ElseBranch::ElseIf(Box::new((*body).try_into()?)) + } + }, + }) + } +} + impl If { fn expand_node(self) -> Result { let If { diff --git a/htmx-macros/src/lib.rs b/htmx-macros/src/lib.rs index 58902a3..11e42d1 100644 --- a/htmx-macros/src/lib.rs +++ b/htmx-macros/src/lib.rs @@ -21,9 +21,9 @@ mod htmx; // more rusty kind of typst syntax: // rtml! {div(attr: "hello") [ {rust block}, "literals"]} #[manyhow(proc_macro)] -pub use htmx::html; +pub use htmx::html::html; #[manyhow(proc_macro)] -pub use htmx::rtml; +pub use htmx::rusty::rtml; // js!{ } diff --git a/src/actix.rs b/src/actix.rs index af43ce3..5436119 100644 --- a/src/actix.rs +++ b/src/actix.rs @@ -7,7 +7,7 @@ use actix_web::http::header::ContentType; use actix_web::web::Bytes; use actix_web::{HttpResponse, Responder}; -use crate::{Css, Html, HtmxSrc}; +use crate::{Css, Html, HtmxSrc, Fragment}; impl Responder for Html { type Body = BoxBody; @@ -19,6 +19,16 @@ impl Responder for Html { } } +impl Responder for Fragment { + type Body = BoxBody; + + fn respond_to(self, _req: &actix_web::HttpRequest) -> HttpResponse { + HttpResponse::Ok() + .content_type(ContentType::html()) + .body(Html::from(self)) + } +} + impl MessageBody for Html { type Error = ::Error; diff --git a/src/attributes.rs b/src/attributes.rs index 04f9a58..37bacbc 100644 --- a/src/attributes.rs +++ b/src/attributes.rs @@ -6,7 +6,7 @@ use std::num::{NonZeroU64, NonZeroU8}; use derive_more::Display; use forr::forr; -use crate::WriteHtml; +use crate::Html; /// An attribute that accepts an attribute value or a flag. pub struct FlagOrValue(PhantomData); @@ -36,19 +36,19 @@ pub enum ValueOrFlag { /// [`Number`]. pub trait ToAttribute { /// Converts into an attribute value. - fn write(&self, html: impl WriteHtml); - fn write_inner(&self, html: impl WriteHtml); + fn write(&self, html: &mut Html); + fn write_inner(&self, html: &mut Html); fn is_unset(&self) -> bool { false } } impl, T> ToAttribute for &A { - fn write(&self, html: impl WriteHtml) { + fn write(&self, html: &mut Html) { >::write(self, html); } - fn write_inner(&self, html: impl WriteHtml) { + fn write_inner(&self, html: &mut Html) { >::write_inner(self, html); } @@ -58,11 +58,11 @@ impl, T> ToAttribute for &A { } impl, T> ToAttribute for Option { - fn write(&self, html: impl WriteHtml) { + fn write(&self, html: &mut Html) { self.as_ref().unwrap().write(html); } - fn write_inner(&self, html: impl WriteHtml) { + fn write_inner(&self, html: &mut Html) { self.as_ref().unwrap().write(html); } @@ -76,10 +76,10 @@ macro_rules! into_attr { forr! { #type:ty in $types #* forr! {#gen:ty in [$target, Any, FlagOrValue<$target>] #* impl ToAttribute<#gen> for #type { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.$fn(self) } - fn write_inner(&self, mut html: impl WriteHtml) { + fn write_inner(&self, html: &mut Html) { html.$fn_inner(self) } } @@ -111,9 +111,9 @@ into_attr! { char, [char], write_attr_value_encoded, write_attr_value_inner_enc // } impl ToAttribute for bool { - fn write(&self, _html: impl WriteHtml) {} + fn write(&self, _html: &mut Html) {} - fn write_inner(&self, _html: impl WriteHtml) {} + fn write_inner(&self, _html: &mut Html) {} fn is_unset(&self) -> bool { !*self @@ -121,9 +121,9 @@ impl ToAttribute for bool { } impl ToAttribute> for bool { - fn write(&self, _html: impl WriteHtml) {} + fn write(&self, _html: &mut Html) {} - fn write_inner(&self, _html: impl WriteHtml) {} + fn write_inner(&self, _html: &mut Html) {} fn is_unset(&self) -> bool { !*self @@ -131,9 +131,9 @@ impl ToAttribute> for bool { } impl ToAttribute for bool { - fn write(&self, _html: impl WriteHtml) {} + fn write(&self, _html: &mut Html) {} - fn write_inner(&self, _html: impl WriteHtml) {} + fn write_inner(&self, _html: &mut Html) {} fn is_unset(&self) -> bool { !*self @@ -146,7 +146,7 @@ impl ToAttribute for bool { /// as the tuples for [`Year`], [`Week`] and [`Day`]. pub trait TimeDateTime { /// Converts into value. - fn write(&self, html: impl WriteHtml); + fn write(&self, html: &mut Html); fn is_unset(&self) -> bool { false @@ -195,20 +195,21 @@ mod chrono { TimeZone, Utc, }; - use super::{Day, TimeDateTime, ToAttribute, Week, WriteHtml, Year}; + use super::{Day, TimeDateTime, ToAttribute, Week, Year}; + use crate::Html; impl ToAttribute for DateTime { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(self.to_rfc3339()); } - fn write_inner(&self, mut html: impl WriteHtml) { + fn write_inner(&self, html: &mut Html) { html.write_attr_value_inner_unchecked(self.to_rfc3339()); } } impl TimeDateTime for (Year, Month) { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(format_args!( "{}-{:02}", self.0, @@ -218,13 +219,13 @@ mod chrono { } impl TimeDateTime for NaiveDate { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(self.format("%Y-%m-%d")); } } impl TimeDateTime for (Month, Day) { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(format_args!( "{:02}-{}", self.0.number_from_month(), @@ -234,53 +235,53 @@ mod chrono { } impl TimeDateTime for NaiveTime { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(self.format("%H:%M:%S.3f").to_string()); } } impl TimeDateTime for NaiveDateTime { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(self.format("%Y-%m-%d %H:%M:%S.3f").to_string()); } } impl TimeDateTime for Utc { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked("Z"); } } impl TimeDateTime for Local { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(Self::now().format("%z")); } } impl TimeDateTime for FixedOffset { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(self.to_string()); } } impl TimeDateTime for DateTime { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(self.to_rfc3339()); } } impl TimeDateTime for (Year, Week) { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(format_args!("{}-{}", self.0, self.1)); } } impl TimeDateTime for Year { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(format_args!("{:04}", self.0)); } } impl TimeDateTime for Duration { - fn write(&self, mut html: impl WriteHtml) { + fn write(&self, html: &mut Html) { html.write_attr_value_unchecked(self); } } diff --git a/src/axum.rs b/src/axum.rs index 62f0f07..3098435 100644 --- a/src/axum.rs +++ b/src/axum.rs @@ -1,6 +1,6 @@ use axum_core::response::IntoResponse; -use crate::{Css, Html, HtmxSrc}; +use crate::{Css, Fragment, Html, HtmxSrc}; impl IntoResponse for Html { fn into_response(self) -> axum_core::response::Response { @@ -12,16 +12,22 @@ impl IntoResponse for Html { } } -impl IntoResponse for Css<'static> { +impl IntoResponse for Fragment { fn into_response(self) -> axum_core::response::Response { ( - [("Content-Type", "text/css; charset=utf-8")], - self.0, + [("Content-Type", "text/html; charset=utf-8")], + Html::from(self).to_string(), ) .into_response() } } +impl IntoResponse for Css<'static> { + fn into_response(self) -> axum_core::response::Response { + ([("Content-Type", "text/css; charset=utf-8")], self.0).into_response() + } +} + impl IntoResponse for HtmxSrc { fn into_response(self) -> axum_core::response::Response { ( diff --git a/src/lib.rs b/src/lib.rs index 3e68975..001ede3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -110,42 +110,87 @@ mod axum; #[doc(hidden)] pub mod __private { - pub use typed_builder; + pub trait Unused { + fn unused() {} + } + impl Unused for T {} + + pub trait Settable { + fn get_or_default(self) -> T + where + T: Default; + } + + pub struct Unset; + impl Settable for Unset { + fn get_or_default(self) -> T + where + T: Default, + { + T::default() + } + } + + #[ghost::phantom] + pub struct Empty; + impl Iterator for Empty { + type Item = I; + + fn next(&mut self) -> Option { + None + } + } + + pub struct Set(pub T); + impl Settable for Set { + fn get_or_default(self) -> T + where + T: Default, + { + self.0 + } + } + impl IntoIterator for Set { + type IntoIter = T::IntoIter; + type Item = T::Item; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } + } } -/// Allows to make a component from a function or struct. -/// -/// # Struct -/// A struct needs to have an [Into]<[Html]> implementation. -/// ``` -/// # use htmx::{component, html, Html}; -/// #[component] -/// struct Component { -/// a: bool, -/// b: String, -/// } -/// -/// impl From for Html { -/// fn from(Component { a, b }: Component) -> Self { -/// html! { -/// -/// } -/// } -/// } -/// -/// html! { -/// -/// -/// -/// -/// }; -/// ``` -/// -/// In the case of struct components, all the [`#[component]`](component) macro -/// does, is generating a derive for [`typed_builder::TypedBuilder`], setting -/// some default attributes, like making [`bool`s](bool) optional and making the -/// builder accept [`Into`]. -/// +/// Allows to make a component from a function. +// # Struct +// A struct needs to have an [Into]<[Html]> implementation. +// ``` +// # use htmx::{component, html, Html}; +// #[component] +// struct Component { +// a: bool, +// b: String, +// } +// +// impl From for Html { +// fn from(Component { a, b }: Component) -> Self { +// html! { +// +// } +// } +// } +// +// html! { +// +// +// +// +// }; +// ``` +// +// In the case of struct components, all the [`#[component]`](component) macro +// does, is generating a derive for [`typed_builder::TypedBuilder`], setting +// some default attributes, like making [`bool`s](bool) optional and making the +// builder accept [`Into`]. /// # Function /// Instead of structs function components are more succinct. /// @@ -154,7 +199,7 @@ pub mod __private { /// ``` /// # use htmx::{component, html, Html}; /// #[component] -/// fn Component(a: bool, b: String) -> Html { +/// fn Component(a: bool, b: String) { /// html! { /// /// } @@ -225,6 +270,7 @@ pub use htmx_macros::component; /// } /// // Closing tags can be inferred. /// } +/// .into_string() /// # ); /// ``` pub use htmx_macros::html; @@ -272,7 +318,7 @@ impl ToJs for T { #[must_use] pub struct Html(String); -impl WriteHtml for Html { +impl Html { fn write_str(&mut self, s: &str) { self.0.push_str(s); } @@ -284,6 +330,44 @@ impl WriteHtml for Html { fn write_fmt(&mut self, a: fmt::Arguments) { self.0.write_fmt(a).unwrap(); } + + fn write_quote(&mut self) { + self.write_char('"'); + } + + fn write_gt(&mut self) { + self.write_char('>'); + } + + fn write_open_tag_unchecked(&mut self, name: impl Display) { + debug_assert!(name.to_string().to_ascii_lowercase().chars().all(|c| matches!(c, '-' | '.' | '0'..='9' | '_' | 'a'..='z' | '\u{B7}' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{203F}'..='\u{2040}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')), + "invalid tag name `{name}`, https://html.spec.whatwg.org/multipage/custom-elements.html#prod-potentialcustomelementname" + ); + write!(self, "<{name}"); + } + + fn write_close_tag_unchecked(&mut self, name: impl Display) { + debug_assert!(name.to_string().to_ascii_lowercase().chars().all(|c| matches!(c, '-' | '.' | '0'..='9' | '_' | 'a'..='z' | '\u{B7}' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{203F}'..='\u{2040}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')), + "invalid tag name `{name}`, https://html.spec.whatwg.org/multipage/custom-elements.html#prod-potentialcustomelementname" + ); + write!(self, ""); + } + + fn write_attr_value_unchecked(&mut self, value: impl Display) { + write!(self, "=\"{value}\""); + } + + fn write_attr_value_inner_unchecked(&mut self, value: impl Display) { + write!(self, "{value}"); + } + + fn write_attr_value_encoded(&mut self, value: impl Display) { + self.write_attr_value_unchecked(encode_double_quoted_attribute(&value.to_string())); + } + + fn write_attr_value_inner_encoded(&mut self, value: impl Display) { + self.write_attr_value_inner_unchecked(encode_double_quoted_attribute(&value.to_string())); + } } impl Html { @@ -389,18 +473,19 @@ impl WriteHtml for ManuallyDrop { /// /// The [`html!`] macro uses them for all tags that contain `-` making it /// possible to use web-components. -pub struct CustomElement { - html: ManuallyDrop, - name: ManuallyDrop>, +#[must_use = "call close or body"] +pub struct CustomElement<'html, S: ElementState> { + html: &'html mut Html, + name: Cow<'html, str>, state: PhantomData, } -impl CustomElement { +impl<'html> CustomElement<'html, Tag> { /// Creates a new HTML element with the specified `name`. /// # Panics /// Panics on [invalid element names](https://html.spec.whatwg.org/multipage/custom-elements.html#prod-potentialcustomelementname). /// Only the character classes are enforced, not the existence of a `-`. - pub fn new(html: Html, name: impl Into>) -> Self { + pub fn new(html: &'html mut Html, name: impl Into>) -> Self { let name = name.into(); assert!(name.to_ascii_lowercase().chars().all(|c| matches!(c, '-' | '.' | '0'..='9' | '_' | 'a'..='z' | '\u{B7}' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{203F}'..='\u{2040}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')), "invalid tag name `{name}`, https://html.spec.whatwg.org/multipage/custom-elements.html#prod-potentialcustomelementname" @@ -414,15 +499,15 @@ impl CustomElement { /// only in debug builds, failing to ensure valid keys can lead to broken /// HTML output. Only the character classes are enforced, not the /// existence of a `-`. - pub fn new_unchecked(mut html: Html, name: impl Into>) -> Self { + pub fn new_unchecked(html: &'html mut Html, name: impl Into>) -> Self { let name = name.into(); debug_assert!(name.to_ascii_lowercase().chars().all(|c| matches!(c, '-' | '.' | '0'..='9' | '_' | 'a'..='z' | '\u{B7}' | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}' | '\u{200C}'..='\u{200D}' | '\u{203F}'..='\u{2040}' | '\u{2070}'..='\u{218F}' | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}' | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}' | '\u{10000}'..='\u{EFFFF}')), "invalid tag name `{name}`, https://html.spec.whatwg.org/multipage/custom-elements.html#prod-potentialcustomelementname" ); write!(html, "<{name}"); Self { - html: ManuallyDrop::new(html), - name: ManuallyDrop::new(name), + html, + name, state: PhantomData, } } @@ -432,23 +517,24 @@ impl CustomElement { /// /// # Panics /// Panics on [invalid attribute names](https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0). - pub fn custom_attr(&mut self, key: impl Display, value: impl ToAttribute) { + pub fn custom_attr(self, key: impl Display, value: impl ToAttribute) -> Self { assert!(!key.to_string().chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), "invalid key `{key}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0"); - self.custom_attr_unchecked(key, value); + self.custom_attr_unchecked(key, value) } /// Sets the attribute `key`, this does not do any type checking and allows /// [`AnyAttributeValue`], without checking for invalid characters. /// /// Note: This function does contain the check for [invalid attribute names](https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0) only in debug builds, failing to ensure valid keys can lead to broken HTML output. - pub fn custom_attr_unchecked(&mut self, key: impl Display, value: impl ToAttribute) { + pub fn custom_attr_unchecked(self, key: impl Display, value: impl ToAttribute) -> Self { debug_assert!(!key.to_string().chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), "invalid key `{key}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0"); write!(self.html, " {key}"); - value.write(&mut self.html); + value.write(self.html); + self } // TODO, use closure like body @@ -470,47 +556,33 @@ impl CustomElement { // self.change_state() // } - pub fn body(mut self, body: impl FnOnce(&mut Html)) { - self.html.write_gt(); - body(&mut self.html); - self.change_state::(); - } -} + pub fn body(self, body: impl IntoHtml) -> impl IntoHtml { + Tag::close_tag(self.html); + body.into_html(self.html); + self.html.write_close_tag_unchecked(self.name.as_ref()); -impl CustomElement { - fn change_state(mut self) -> CustomElement { - let html = unsafe { ManuallyDrop::take(&mut self.html) }; - let name = unsafe { ManuallyDrop::take(&mut self.name) }; - std::mem::forget(self); - CustomElement { - html: ManuallyDrop::new(html), - name: ManuallyDrop::new(name), - state: PhantomData, - } + Fragment::EMPTY } -} -impl Drop for CustomElement { - fn drop(&mut self) { - S::close_tag(&mut self.html); - self.html.write_close_tag_unchecked(self.name.as_ref()); + pub fn close(self) -> impl IntoHtml { + self.body(Fragment::EMPTY) } } -/// Puts content directly into HTML bypassing HTML-escaping. +/// Puts content directly into HTML (or CSS/JS), bypassing HTML-escaping. /// /// ``` -/// # use htmx::{html, RawHtml}; -/// # insta::assert_display_snapshot!("doc-RawHtml", +/// # use htmx::{html, RawSrc}; +/// # insta::assert_display_snapshot!("doc-RawSrc", /// html! { /// "this < will be > escaped " -/// not")/> +/// not")/> /// } /// # ); /// ``` -pub struct RawHtml<'a>(pub Cow<'a, str>); +pub struct RawSrc<'a>(pub Cow<'a, str>); -impl<'a> RawHtml<'a> { +impl<'a> RawSrc<'a> { /// Creates a new `RawHtml`. pub fn new(content: impl Into>) -> Self { Self(content.into()) @@ -519,48 +591,94 @@ impl<'a> RawHtml<'a> { pub struct Fragment(pub F); -// TODO reconsider elements implementing WriteHtml, maybe it would be better for them to implement a way to access the underlying `Html` -impl Fragment { - #[allow(non_snake_case)] - pub fn EMPTY(self, html: impl WriteHtml) {} +impl Fragment { + pub const EMPTY: Self = Self(|_| {}); } -impl IntoHtml for Fragment { - fn into_html(self, html: Html) { +impl From> for Html { + fn from(val: Fragment) -> Self { + let mut html = Html::new(); + val.into_html(&mut html); + html + } +} + +impl Fragment { + pub fn into_string(self) -> String { + Html::from(self).0 + } + + pub fn into_html(self, html: &mut Html) { self.0(html); } } -pub trait IntoHtml { - fn into_html(self, html: Html); +impl Display for Fragment { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut html = Html::new(); + self.0(&mut html); + html.fmt(f) + } } -impl IntoHtml for T { - fn into_html(self, html: Html) { +impl IntoHtml for Fragment { + fn into_html(self, html: &mut Html) { + self.0(html); + } +} +impl IntoStyle for Fragment { + fn into_style(self, html: &mut Html) { + self.0(html); + } +} +impl IntoScript for Fragment { + fn into_script(self, html: &mut Html) { + self.0(html); + } +} + +pub trait IntoHtml { + fn into_html(self, html: &mut Html); +} + +impl IntoHtml for T { + fn into_html(self, html: &mut Html) { self.to_html(html); } } pub trait ToHtml { - fn to_html(&self, html: impl WriteHtml); + fn to_html(&self, html: &mut Html); } impl ToHtml for &T { - fn to_html(&self, html: impl WriteHtml) { + fn to_html(&self, html: &mut Html) { T::to_html(self, html); } } impl ToHtml for Option { - fn to_html(&self, html: impl WriteHtml) { + fn to_html(&self, html: &mut Html) { if let Some(it) = self { it.to_html(html); } } } -impl ToHtml for RawHtml<'_> { - fn to_html(&self, mut html: impl WriteHtml) { +impl ToHtml for RawSrc<'_> { + fn to_html(&self, html: &mut Html) { + html.write_str(&self.0); + } +} + +impl ToScript for RawSrc<'_> { + fn to_script(&self, html: &mut Html) { + html.write_str(&self.0); + } +} + +impl ToStyle for RawSrc<'_> { + fn to_style(&self, html: &mut Html) { html.write_str(&self.0); } } @@ -569,7 +687,7 @@ impl ToHtml for RawHtml<'_> { pub struct Css<'a>(pub Cow<'a, str>); impl ToHtml for Css<'_> { - fn to_html(&self, _html: impl WriteHtml) { + fn to_html(&self, _html: &mut Html) { todo!() // TODO: style::new(html).child(self.0.as_ref()).close(); } @@ -578,7 +696,7 @@ impl ToHtml for Css<'_> { pub struct Tag; impl ElementState for Tag { - fn close_tag(mut html: impl WriteHtml) { + fn close_tag(html: &mut Html) { html.write_gt(); } } @@ -587,7 +705,7 @@ forr! { $ty:ty in [CustomAttr, StyleAttr, ClassesAttr] $* pub struct $ty; impl ElementState for $ty { - fn close_tag(mut html: impl WriteHtml) { + fn close_tag(html: &mut Html) { html.write_quote(); html.write_gt(); } @@ -597,55 +715,75 @@ forr! { $ty:ty in [CustomAttr, StyleAttr, ClassesAttr] $* pub struct Body; impl ElementState for Body { - fn close_tag(_: impl WriteHtml) {} + fn close_tag(_: &mut Html) {} } pub trait ElementState { - fn close_tag(html: impl WriteHtml); + fn close_tag(html: &mut Html); } forr! {$type:ty in [&str, String, Cow<'_, str>]$* impl ToHtml for $type { - fn to_html(&self, mut out: impl WriteHtml) { + fn to_html(&self, out: &mut Html) { write!(out, "{}", html_escape::encode_text(&self)); } } impl ToScript for $type { - fn to_script(&self, mut out: impl WriteHtml) { + fn to_script(&self, out: &mut Html) { write!(out, "{}", html_escape::encode_script(&self)); } } impl ToStyle for $type { - fn to_style(&self, mut out: impl WriteHtml) { + fn to_style(&self, out: &mut Html) { write!(out, "{}", html_escape::encode_style(&self)); } } } impl ToHtml for char { - fn to_html(&self, mut out: impl WriteHtml) { + fn to_html(&self, out: &mut Html) { write!(out, "{}", html_escape::encode_text(&self.to_string())); } } pub trait ToScript { - fn to_script(&self, out: impl WriteHtml); + fn to_script(&self, out: &mut Html); } impl ToScript for &T { - fn to_script(&self, out: impl WriteHtml) { + fn to_script(&self, out: &mut Html) { T::to_script(self, out); } } +pub trait IntoScript { + fn into_script(self, html: &mut Html); +} + +impl IntoScript for T { + fn into_script(self, html: &mut Html) { + self.to_script(html); + } +} + pub trait ToStyle { - fn to_style(&self, out: impl WriteHtml); + fn to_style(&self, out: &mut Html); } impl ToStyle for &T { - fn to_style(&self, out: impl WriteHtml) { + fn to_style(&self, out: &mut Html) { T::to_style(self, out); } } + +pub trait IntoStyle { + fn into_style(self, html: &mut Html); +} + +impl IntoStyle for T { + fn into_style(self, html: &mut Html) { + self.to_style(html); + } +} diff --git a/src/native.rs b/src/native.rs index 03576fa..21e02f7 100644 --- a/src/native.rs +++ b/src/native.rs @@ -3,15 +3,11 @@ use std::fmt::Display; use std::marker::PhantomData; -use std::mem::ManuallyDrop; use forr::{forr, iff}; use crate::attributes::{Any, DateTime, FlagOrValue, Number, TimeDateTime, ToAttribute}; -use crate::{ - Body, ClassesAttr, CustomAttr, ElementState, StyleAttr, Tag, ToHtml, ToScript, ToStyle, - WriteHtml, -}; +use crate::{ElementState, Html, IntoHtml, IntoScript, IntoStyle, Tag, Fragment}; macro_rules! attribute { ($elem:ident|$name:ident) => { @@ -46,11 +42,12 @@ macro_rules! attribute { macro_rules! attr_fn{ ($($doc:expr)?, $name:ident, $actual:tt, $type:ty) => { $(#[doc = $doc])? - pub fn $name(&mut self, value: $type) { + pub fn $name(mut self, value: $type) -> Self { if !value.is_unset() { write!(self.html, " {}", $actual); value.write(&mut self.html); } + self } } } @@ -105,7 +102,7 @@ forr! { ($type:ty, $attrs:tt) in [ (track, [default, kind/*subtitles,captions,descriptions,chapters,metadata*/, label, src, srclang]), (video, [autoplay, controls, crossorigin/*anonymous, use-credentials*/, height, loop_="loop", muted, playsinline, poster, preload/*none,metadata,auto*/, src, width]) ] $* - impl $type { + impl $type<'_, Tag> { forr! { $attr:ty in $attrs $* attribute!($type|$attr); } @@ -115,33 +112,26 @@ forr! { ($type:ty, $attrs:tt) in [ forr! { $type:ty in [a, abbr, address, area, article, aside, audio, b, base, bdi, bdo, blockquote, body, br, button, canvas, caption, cite, code, col, colgroup, data, datalist, dd, del, details, dfn, dialog, dl, dt, em, embeded, div, fieldset, figcaption, figure, footer, form, h1, h2, h3, h4, h5, h6, head, header, hgroup, hr, html, i, iframe, img, input, ins, kbd, label, legend, li, link, main, map, mark, menu, meta, meter, nav, noscript, object, ol, optgroup, option, output, p, picture, pre, progress, q, rp, rt, ruby, s, samp, script, search, section, select, slot, small, source, span, strong, style, sub, summary, sup, table, tbody, td, template, textarea, tfoot, th, thead, time, title, tr, track, u, ul, var, video, wbr, xmp] $* #[doc = concat!("The [`<", stringify!($type), ">`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/", stringify!($type), ") element.")] - pub struct $type { - html: ManuallyDrop, + pub struct $type<'html, Attr: ElementState> { + html: &'html mut Html, state: PhantomData } - impl $type { + impl $type<'_, Tag> { #[doc(hidden)] pub fn unused() {} } - impl $type { + impl<'html> $type<'html, Tag> { - pub fn new(mut html: T) -> Self { + pub fn new(html: &'html mut Html) -> Self { html.write_open_tag_unchecked(stringify!($type)); Self { - html: ManuallyDrop::new(html), + html: html, state: PhantomData } } - iff! {!equals_any($type)[(area), (base), (br), (col), (embeded), (hr), (input), (link), (meta), (source), (track), (wbr)] $: - pub fn body(mut self, body: impl FnOnce(&mut T)) -> $type { - self.html.write_gt(); - body(&mut self.html); - self.change_state() - } - } // iff! {equals($type)(script) $: // /// Adds JS code to the script. @@ -165,27 +155,27 @@ forr! { $type:ty in [a, abbr, address, area, article, aside, audio, b, base, bdi /// /// # Panics /// Panics on [invalid attribute names](https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0). - pub fn custom_attr( &mut self, key: impl Display, value: impl ToAttribute) - { + pub fn custom_attr( self, key: impl Display, value: impl ToAttribute) -> Self { assert!(!key.to_string().chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), "invalid key `{key}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0"); - self.custom_attr_unchecked(key, value); + self.custom_attr_unchecked(key, value) } - /// Sets a custom attribute. - /// - /// Useful for setting, e.g., `data-{key}`. - /// - /// # Panics - /// Panics on [invalid attribute names](https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0). - pub fn custom_attr_list(self, key: impl Display) -> $type - { - assert!(!key.to_string().chars().any(|c| c.is_whitespace() - || c.is_control() - || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), "invalid key `{key}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0"); - self.custom_attr_list_unchecked(key) - } + // TODO + // /// Sets a custom attribute. + // /// + // /// Useful for setting, e.g., `data-{key}`. + // /// + // /// # Panics + // /// Panics on [invalid attribute names](https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0). + // pub fn custom_attr_list(self, key: impl Display) -> $type + // { + // assert!(!key.to_string().chars().any(|c| c.is_whitespace() + // || c.is_control() + // || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), "invalid key `{key}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0"); + // self.custom_attr_list_unchecked(key) + // } /// Sets a custom attribute, without checking for valid keys. @@ -193,46 +183,50 @@ forr! { $type:ty in [a, abbr, address, area, article, aside, audio, b, base, bdi /// Useful for setting, e.g., `data-{key}`. /// /// Note: This function does contain the check for [invalid attribute names](https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0) only in debug builds, failing to ensure valid keys can lead to broken HTML output. - pub fn custom_attr_unchecked(&mut self, key: impl Display, value: impl ToAttribute) + pub fn custom_attr_unchecked(mut self, key: impl Display, value: impl ToAttribute) -> Self { debug_assert!(!key.to_string().chars().any(|c| c.is_whitespace() || c.is_control() || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), "invalid key `{key}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0"); write!(self.html, " {key}"); value.write(&mut self.html); + self } - /// Sets a custom attribute, without checking for valid keys. - /// - /// Useful for setting, e.g., `data-{key}`. - /// - /// Note: This function does contain the check for [invalid attribute names](https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0) only in debug builds, failing to ensure valid keys can lead to broken HTML output. - pub fn custom_attr_list_unchecked(mut self, key: impl Display) -> $type - { - debug_assert!(!key.to_string().chars().any(|c| c.is_whitespace() - || c.is_control() - || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), "invalid key `{key}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0"); - write!(self.html, " {key}=\""); - self.change_state() - } + // TODO + // /// Sets a custom attribute, without checking for valid keys. + // /// + // /// Useful for setting, e.g., `data-{key}`. + // /// + // /// Note: This function does contain the check for [invalid attribute names](https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0) only in debug builds, failing to ensure valid keys can lead to broken HTML output. + // pub fn custom_attr_list_unchecked(mut self, key: impl Display) -> $type + // { + // debug_assert!(!key.to_string().chars().any(|c| c.is_whitespace() + // || c.is_control() + // || matches!(c, '\0' | '"' | '\'' | '>' | '/' | '=')), "invalid key `{key}`, https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#attributes-0"); + // write!(self.html, " {key}=\""); + // self.change_state() + // } - /// Adds classes to the element. - pub fn classes(mut self) -> $type { - write!(self.html, " classes=\""); - self.change_state() - } + // TODO + // /// Adds classes to the element. + // pub fn class(mut self, value: impl ToAttribute<) -> $type { + // write!(self.html, " classes=\""); + // self.change_state() + // } - /// Adds styles to the element. - pub fn style(mut self) -> $type { - write!(self.html, " style=\""); - self.change_state() - } + // TODO + // /// Adds styles to the element. + // pub fn style(mut self) -> $type { + // write!(self.html, " style=\""); + // self.change_state() + // } // Global attributes // TODO class should be able to specify multiple times forr! { $attr:ty in [ // TODO ARIA: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes - accesskey, autocapitalize/*off/none, on/sentence, words, characters*/, autofocus, contenteditable/*true, false, plaintext-only*/, dir/*ltr,rtl,auto*/, draggable/*true,false*/, enterkeyhint,hidden>/*hidden|until-found*/, id, inert, inputmode/*none,text,decimal,numeric,tel,search,email,url*/, is, itemid, itemprop, itemref, itemscope, itemtype, lang, nonce, part, popover, rolle, slot, spellcheck>/*true,false*/, tabindex, title, translate/*yes,no*/, virtualkeyboardpolicy/*auto,manual*/] $* + class, accesskey, autocapitalize/*off/none, on/sentence, words, characters*/, autofocus, contenteditable/*true, false, plaintext-only*/, dir/*ltr,rtl,auto*/, draggable/*true,false*/, enterkeyhint,hidden>/*hidden|until-found*/, id, inert, inputmode/*none,text,decimal,numeric,tel,search,email,url*/, is, itemid, itemprop, itemref, itemscope, itemtype, lang, nonce, part, popover, rolle, slot, spellcheck>/*true,false*/, tabindex, title, translate/*yes,no*/, virtualkeyboardpolicy/*auto,manual*/] $* attribute!(global|$attr); } // Event handlers @@ -245,108 +239,64 @@ forr! { $type:ty in [a, abbr, address, area, article, aside, audio, b, base, bdi iff! {!equals_any($type)[(area), (base), (br), (col), (embeded), (hr), (input), (link), (meta), (source), (track), (wbr)] $: - impl $type { + impl $type<'_, Attr> { iff! {equals($type)(script) $: - pub fn child_expr(mut self, child: impl ToScript) -> Self { - child.to_script(&mut self); - self + pub fn body(mut self, body: impl IntoScript) -> impl IntoHtml { + Attr::close_tag(&mut self.html); + body.into_script(&mut self.html); + self.html.write_close_tag_unchecked(stringify!($type)); + Fragment::EMPTY } } iff! {equals($type)(style) $: - pub fn child_expr(mut self, child: impl ToStyle) -> Self { - child.to_style(&mut self); - self + pub fn body(mut self, body: impl IntoStyle) -> impl IntoHtml { + Attr::close_tag(&mut self.html); + body.into_style(&mut self.html); + self.html.write_close_tag_unchecked(stringify!($type)); + Fragment::EMPTY } } iff! {!equals_any($type)[(style), (script)] $: - pub fn child_expr(mut self, child: impl ToHtml) -> Self { - child.to_html(&mut self); - self - } - - pub fn child(self, child: impl FnOnce(Self) -> C) -> C { - child(self) + pub fn body(mut self, body: impl IntoHtml) -> impl IntoHtml { + Attr::close_tag(&mut self.html); + body.into_html(&mut self.html); + self.html.write_close_tag_unchecked(stringify!($type)); + Fragment::EMPTY } } } - impl WriteHtml for $type { - fn write_str(&mut self, s: &str) { - self.html.write_str(s); - } - - fn write_char(&mut self, c: char) { - self.html.write_char(c); - } - - fn write_fmt(&mut self, a: std::fmt::Arguments) { - self.html.write_fmt(a); - } - - } - - impl $type { - pub fn close(mut self) -> Html { - S::close_tag(&mut self.html); - self.html.write_close_tag_unchecked(stringify!($type)); - let html = unsafe { ManuallyDrop::take(&mut self.html) }; - std::mem::forget(self); - html - } - } - - impl Drop for $type { - fn drop(&mut self) { - Attr::close_tag(&mut self.html); - self.html.write_close_tag_unchecked(stringify!($type)); + impl $type<'_, Attr> { + pub fn close(self) -> impl IntoHtml { + self.body(::htmx::Fragment::EMPTY) } } } iff! {equals_any($type)[(area), (base), (br), (col), (embeded), (hr), (input), (link), (meta), (source), (track), (wbr)] $: - impl Drop for $type { - fn drop(&mut self) { + impl $type<'_, Attr> { + pub fn close(mut self) -> impl IntoHtml { Attr::close_tag(&mut self.html); - self.html.write_close_tag_unchecked(stringify!($type)); - } - } - - impl $type { - pub fn close(mut self) -> Html { - S::close_tag(&mut self.html); - let html = unsafe { ManuallyDrop::take(&mut self.html) }; - std::mem::forget(self); - html + Fragment::EMPTY } } } - impl $type { - fn change_state(mut self) -> $type { - let html = unsafe { ManuallyDrop::take(&mut self.html) }; - std::mem::forget(self); - $type { - html: ManuallyDrop::new(html), - state: PhantomData - } - } - - } - - - forr! {$Attr:ty in [CustomAttr, ClassesAttr, StyleAttr] $* - impl $type { - pub fn add(mut self, value: impl Display) -> Self { - write!(self.html, "; {value}"); - self - } - pub fn close_attr(mut self) -> $type { - self.html.write_quote(); - self.change_state() - } - } - } + // TODO + // forr! {$Attr:ty in [CustomAttr, ClassesAttr, StyleAttr] $* + // impl $type<$Attr> { + // pub fn add(mut self, value: impl Display) -> Self { + // write!(self.html, "; {value}"); + // self + // } + + // pub fn close_attr(mut self) -> $type { + // self.html.write_quote(); + // self.change_state() + // } + // } + // } } diff --git a/src/snapshots/doctest_lib_rs__doc-RawSrc.snap b/src/snapshots/doctest_lib_rs__doc-RawSrc.snap new file mode 100644 index 0000000..151ec4e --- /dev/null +++ b/src/snapshots/doctest_lib_rs__doc-RawSrc.snap @@ -0,0 +1,5 @@ +--- +source: src/lib.rs +expression: "html! { \"this < will be > escaped \" not\")/> }" +--- +this < will be > escaped This < will > not diff --git a/src/utils.rs b/src/utils.rs index 6e40fae..62364b8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,5 @@ -use crate::{IntoHtml, ToHtml, ToScript, WriteHtml}; +use crate::attributes::ToAttribute; +use crate::{html, Html, IntoHtml, ToHtml, ToScript}; /// Embed [HTMX script](https://htmx.org/). /// @@ -7,42 +8,36 @@ use crate::{IntoHtml, ToHtml, ToScript, WriteHtml}; /// /// [`v1.9.5`](https://github.com/bigskysoftware/htmx/releases/tag/v1.9.5) #[must_use] +#[derive(Clone, Copy)] pub struct HtmxSrc; impl HtmxSrc { - // TODO add preescaped variant /// HTMX source. pub const HTMX_SRC: &'static str = include_str!("htmx.min.js"); #[allow(clippy::new_ret_no_self)] - pub fn new(html: H) -> ExprHtml { - ExprHtml::new(Self, html) + pub fn new(_: &mut Html) -> ExprHtml { + ExprHtml(Self) } } impl ToHtml for HtmxSrc { - fn to_html(&self, mut html: impl crate::WriteHtml) { - crate::html! {html => - - }; + fn to_html(&self, html: &mut Html) { + crate::html! {}.into_html(html); } } impl ToScript for HtmxSrc { - fn to_script(&self, out: impl WriteHtml) { + fn to_script(&self, out: &mut Html) { Self::HTMX_SRC.to_script(out); } } -pub struct ExprHtml(H); - -impl ExprHtml { - pub fn new(to_html: impl ToHtml, mut html: H) -> Self { - to_html.to_html(&mut html); - Self(html) - } +#[must_use] +pub struct ExprHtml(T); - pub fn close(self) -> H { +impl ExprHtml { + pub fn close(self) -> impl IntoHtml { self.0 } } @@ -58,156 +53,41 @@ impl, I: IntoIterator> From for AttrVec { } } -// /// HTML boilerplate -// #[component] -// pub fn HtmlPage( -// /// Sets `` to specify page supports mobile -// /// form factor. -// mobile: bool, -// /// `{}` -// #[default] -// title: String, -// /// `` -// #[default] -// AttrVec(style_sheets): AttrVec, -// /// ` +Title diff --git a/tests/utils.rs b/tests/utils.rs index 6587ed0..2c2e82a 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -5,12 +5,12 @@ use insta::assert_snapshot; fn html_page() { assert_snapshot!( html! { - + } - .to_string() + .into_string() .as_str() ) } diff --git a/tests/wip.rs b/tests/wip.rs deleted file mode 100644 index 08e2c23..0000000 --- a/tests/wip.rs +++ /dev/null @@ -1,6 +0,0 @@ -use htmx::{component, html, rtml, Html}; - -#[component] -fn comp() -> Html { - rtml!() -}