initial commit
This commit is contained in:
commit
6349ff9412
16 changed files with 2201 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/target
|
1209
Cargo.lock
generated
Normal file
1209
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
26
Cargo.toml
Normal file
26
Cargo.toml
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
".",
|
||||||
|
"vespid",
|
||||||
|
"vespid/macros"
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
vespid.path = "vespid"
|
||||||
|
axum = "0.7"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
[package]
|
||||||
|
name = "crusto"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
vespid.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
eyre = "0.6.12"
|
||||||
|
color-eyre = "0.6.3"
|
||||||
|
tracing = "0.1.41"
|
||||||
|
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||||
|
tracing-error = "0.2.1"
|
11
LICENSE
Normal file
11
LICENSE
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
Anti-GitHub License (AGHL) v1 (based on the MIT license)
|
||||||
|
|
||||||
|
Copyright (c) 2024 Ilya Borodinov <borodinov.ilya@gmail.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
2. The Software shall not be published, distributed, or otherwise made available on GitHub.com or any of its subdomains or affiliated websites. Failure to comply with this condition will immediately revoke the rights granted hereunder.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
24
README.md
Normal file
24
README.md
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# crusto - the git forge you never knew you needed
|
||||||
|
|
||||||
|
[![License: AGHL v1](https://badgers.space/badge/License/AGHL%20v1/blue)](./LICENSE)
|
||||||
|
[![Please don't upload to GitHub](https://nogithub.codeberg.page/badge.svg)](https://nogithub.codeberg.page)
|
||||||
|
|
||||||
|
crusto is a git forge written in Rust, that uses:
|
||||||
|
- vespid, a custom built in-tree (for now) SSR framework
|
||||||
|
- htmx, as the HATEOAS client layer
|
||||||
|
- axum, as the web server
|
||||||
|
- sleep deprivation, as the motivation
|
||||||
|
|
||||||
|
## how to run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo r
|
||||||
|
```
|
||||||
|
|
||||||
|
You can set `RUST_LOG=crusto=trace` to see traces with TRACE and up. By default it logs INFO and up traces.
|
||||||
|
|
||||||
|
## roadmap
|
||||||
|
|
||||||
|
- [ ] repo shepherding
|
||||||
|
- [ ] auth and users
|
||||||
|
- [ ] repo creation
|
54
src/main.rs
Normal file
54
src/main.rs
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
#[macro_use]
|
||||||
|
extern crate tracing;
|
||||||
|
|
||||||
|
use axum::{response::Html, routing::get};
|
||||||
|
use tracing::level_filters::LevelFilter;
|
||||||
|
use tracing_subscriber::layer::SubscriberExt;
|
||||||
|
use vespid::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
async fn Index() -> String {
|
||||||
|
info!("Index");
|
||||||
|
html! {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Crusto</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
"Welcome to Crusto!"
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index() -> Html<String> {
|
||||||
|
vespid::axum_compat::render(async move {
|
||||||
|
html! {<Index/>}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> eyre::Result<()> {
|
||||||
|
color_eyre::install()?;
|
||||||
|
let registry = tracing_subscriber::registry().with(
|
||||||
|
tracing_subscriber::EnvFilter::builder()
|
||||||
|
.with_default_directive(LevelFilter::INFO.into())
|
||||||
|
.from_env_lossy(),
|
||||||
|
);
|
||||||
|
tracing::subscriber::set_global_default(
|
||||||
|
registry
|
||||||
|
.with(tracing_error::ErrorLayer::default())
|
||||||
|
.with(tracing_subscriber::fmt::layer()),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let app = axum::Router::new().route("/", get(index));
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
|
info!("listening on {}", listener.local_addr()?);
|
||||||
|
axum::serve(listener, app).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
12
vespid/Cargo.toml
Normal file
12
vespid/Cargo.toml
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
[package]
|
||||||
|
name = "vespid"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
html-escape = "0.2.13"
|
||||||
|
tokio.workspace = true
|
||||||
|
tokio-util = { version = "0.7.13", features = ["rt"] }
|
||||||
|
axum.workspace = true
|
||||||
|
vespid_macros.path = "macros"
|
||||||
|
typed-builder = "0.20.0"
|
17
vespid/macros/Cargo.toml
Normal file
17
vespid/macros/Cargo.toml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
[package]
|
||||||
|
name = "vespid_macros"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
syn = { version = "2", features = ["full"] }
|
||||||
|
rstml = "0.12"
|
||||||
|
proc-macro2 = "1"
|
||||||
|
quote = "1"
|
||||||
|
proc-macro-error2 = "2"
|
||||||
|
proc-macro2-diagnostics = "0.10.1"
|
||||||
|
convert_case = "0.6.0"
|
||||||
|
html-escape = "0.2.13"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
proc-macro = true
|
502
vespid/macros/src/lib.rs
Normal file
502
vespid/macros/src/lib.rs
Normal file
|
@ -0,0 +1,502 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use proc_macro::TokenStream;
|
||||||
|
use proc_macro2_diagnostics::Diagnostic;
|
||||||
|
use quote::{quote, quote_spanned, ToTokens};
|
||||||
|
use rstml::{
|
||||||
|
node::{KeyedAttribute, Node, NodeAttribute, NodeElement, NodeName}, Infallible, Parser, ParserConfig
|
||||||
|
};
|
||||||
|
use syn::punctuated::Punctuated;
|
||||||
|
use syn::{parse::Parse, parse_quote, spanned::Spanned, Expr, ExprLit, FnArg, ItemStruct, Token};
|
||||||
|
|
||||||
|
#[proc_macro]
|
||||||
|
pub fn html(tokens: TokenStream) -> TokenStream {
|
||||||
|
html_inner(tokens, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[proc_macro]
|
||||||
|
pub fn html_ide(tokens: TokenStream) -> TokenStream {
|
||||||
|
html_inner(tokens, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_empty_element(name: &str) -> bool {
|
||||||
|
// https://developer.mozilla.org/en-US/docs/Glossary/Empty_element
|
||||||
|
match name {
|
||||||
|
"img" | "input" | "meta" | "link" | "hr" | "br" | "source" | "track" | "wbr" | "area"
|
||||||
|
| "base" | "col" | "embed" | "param" => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_elements_set() -> HashSet<&'static str> {
|
||||||
|
[
|
||||||
|
"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param",
|
||||||
|
"source", "track", "wbr",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn html_inner(tokens: TokenStream, ide_helper: bool) -> TokenStream {
|
||||||
|
let config = ParserConfig::new()
|
||||||
|
.recover_block(true)
|
||||||
|
.element_close_use_default_wildcard_ident(true)
|
||||||
|
.always_self_closed_elements(empty_elements_set());
|
||||||
|
|
||||||
|
let parser = Parser::new(config);
|
||||||
|
let (nodes, errors) = parser.parse_recoverable(tokens).split_vec();
|
||||||
|
process_nodes(ide_helper, &nodes, errors).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn process_nodes<'n>(
|
||||||
|
ide_helper: bool,
|
||||||
|
nodes: &'n Vec<Node>,
|
||||||
|
errors: Vec<Diagnostic>,
|
||||||
|
) -> proc_macro2::TokenStream {
|
||||||
|
let WalkNodesOutput {
|
||||||
|
static_format: html_string,
|
||||||
|
values,
|
||||||
|
collected_elements: elements,
|
||||||
|
diagnostics,
|
||||||
|
} = walk_nodes(&nodes);
|
||||||
|
let docs = if ide_helper {
|
||||||
|
generate_tags_docs(elements)
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
};
|
||||||
|
let errors = errors
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.emit_as_expr_tokens())
|
||||||
|
.chain(diagnostics);
|
||||||
|
quote! {
|
||||||
|
{
|
||||||
|
// Make sure that "compile_error!(..);" can be used in this context.
|
||||||
|
#(#errors;)*
|
||||||
|
// Make sure that "enum x{};" and "let _x = crate::element;" can be used in this context
|
||||||
|
#(#docs;)*
|
||||||
|
format!(#html_string, #(vespid::FormatRender::new(#values)),*)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_tags_docs(elements: Vec<&NodeName>) -> Vec<proc_macro2::TokenStream> {
|
||||||
|
// Mark some of elements as type,
|
||||||
|
// and other as elements as fn in crate::docs,
|
||||||
|
// to give an example how to link tag with docs.
|
||||||
|
let elements_as_type: HashSet<&'static str> =
|
||||||
|
vec!["html", "head", "meta", "link", "body", "div"]
|
||||||
|
.into_iter()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
elements
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| {
|
||||||
|
if elements_as_type.contains(&*e.to_string()) {
|
||||||
|
let element = quote_spanned!(e.span() => enum);
|
||||||
|
quote!({#element X{}})
|
||||||
|
} else {
|
||||||
|
// let _ = crate::docs::element;
|
||||||
|
let element = quote_spanned!(e.span() => element);
|
||||||
|
quote!(let _ = crate::docs::#element)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct WalkNodesOutput<'a> {
|
||||||
|
static_format: String,
|
||||||
|
// Use proc_macro2::TokenStream instead of syn::Expr
|
||||||
|
// to provide more errors to the end user.
|
||||||
|
values: Vec<proc_macro2::TokenStream>,
|
||||||
|
// Additional diagnostic messages.
|
||||||
|
diagnostics: Vec<proc_macro2::TokenStream>,
|
||||||
|
// Collect elements to provide semantic highlight based on element tag.
|
||||||
|
// No differences between open tag and closed tag.
|
||||||
|
// Also multiple tags with same name can be present,
|
||||||
|
// because we need to mark each of them.
|
||||||
|
collected_elements: Vec<&'a NodeName>,
|
||||||
|
}
|
||||||
|
impl<'a> WalkNodesOutput<'a> {
|
||||||
|
fn extend(&mut self, other: WalkNodesOutput<'a>) {
|
||||||
|
self.static_format.push_str(&other.static_format);
|
||||||
|
self.values.extend(other.values);
|
||||||
|
self.diagnostics.extend(other.diagnostics);
|
||||||
|
self.collected_elements.extend(other.collected_elements);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_nodes<'a>(nodes: &'a Vec<Node>) -> WalkNodesOutput<'a> {
|
||||||
|
let mut out = WalkNodesOutput::default();
|
||||||
|
|
||||||
|
for node in nodes {
|
||||||
|
match node {
|
||||||
|
Node::Doctype(doctype) => {
|
||||||
|
let value = &doctype.value.to_token_stream_string();
|
||||||
|
out.static_format.push_str(&format!("<!DOCTYPE {}>", value));
|
||||||
|
}
|
||||||
|
Node::Element(element) => {
|
||||||
|
let name = element.name().to_string();
|
||||||
|
|
||||||
|
if !is_component_tag_name(&name) {
|
||||||
|
match element.name() {
|
||||||
|
NodeName::Block(block) => {
|
||||||
|
out.static_format.push_str("<{}");
|
||||||
|
out.values.push(block.to_token_stream());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
out.static_format.push_str(&format!("<{}", name));
|
||||||
|
out.collected_elements.push(&element.open_tag.name);
|
||||||
|
if let Some(e) = &element.close_tag {
|
||||||
|
out.collected_elements.push(&e.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// attributes
|
||||||
|
for attribute in element.attributes() {
|
||||||
|
match attribute {
|
||||||
|
NodeAttribute::Block(block) => {
|
||||||
|
// If the nodes parent is an attribute we prefix with whitespace
|
||||||
|
out.static_format.push(' ');
|
||||||
|
out.static_format.push_str("{}");
|
||||||
|
out.values.push(block.to_token_stream());
|
||||||
|
}
|
||||||
|
NodeAttribute::Attribute(attribute) => {
|
||||||
|
let (static_format, value) = walk_attribute(attribute);
|
||||||
|
out.static_format.push_str(&static_format);
|
||||||
|
if let Some(value) = value {
|
||||||
|
out.values.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ignore childs of special Empty elements
|
||||||
|
if is_empty_element(element.open_tag.name.to_string().as_str()) {
|
||||||
|
out.static_format.push_str(" />");
|
||||||
|
if !element.children.is_empty() {
|
||||||
|
let warning = proc_macro2_diagnostics::Diagnostic::spanned(
|
||||||
|
element.open_tag.name.span(),
|
||||||
|
proc_macro2_diagnostics::Level::Warning,
|
||||||
|
"Element is processed as empty, and cannot have any child",
|
||||||
|
);
|
||||||
|
out.diagnostics.push(warning.emit_as_expr_tokens())
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.static_format.push('>');
|
||||||
|
|
||||||
|
// children
|
||||||
|
let other_output = walk_nodes(&element.children);
|
||||||
|
out.extend(other_output);
|
||||||
|
|
||||||
|
match element.name() {
|
||||||
|
NodeName::Block(block) => {
|
||||||
|
out.static_format.push_str("</{}>");
|
||||||
|
out.values.push(block.to_token_stream());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
out.static_format.push_str(&format!("</{}>", name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// custom elements
|
||||||
|
out.static_format.push_str("{}");
|
||||||
|
out.values
|
||||||
|
.push(CustomElement::new(element).to_token_stream());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Node::Text(text) => {
|
||||||
|
out.static_format.push_str(&text.value_string());
|
||||||
|
}
|
||||||
|
Node::RawText(text) => {
|
||||||
|
out.static_format.push_str(&text.to_string_best());
|
||||||
|
}
|
||||||
|
Node::Fragment(fragment) => {
|
||||||
|
let other_output = walk_nodes(&fragment.children);
|
||||||
|
out.extend(other_output)
|
||||||
|
}
|
||||||
|
Node::Comment(comment) => {
|
||||||
|
out.static_format.push_str("<!-- {} -->");
|
||||||
|
out.values.push(comment.value.to_token_stream());
|
||||||
|
}
|
||||||
|
Node::Block(block) => {
|
||||||
|
let block = block.try_block().unwrap();
|
||||||
|
let stmts = &block.stmts;
|
||||||
|
out.static_format.push_str("{}");
|
||||||
|
out.values.push(quote!(#(#stmts)*));
|
||||||
|
}
|
||||||
|
Node::Custom(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk_attribute(attribute: &KeyedAttribute) -> (String, Option<proc_macro2::TokenStream>) {
|
||||||
|
let mut static_format = String::new();
|
||||||
|
let mut format_value = None;
|
||||||
|
let key = match attribute.key.to_string().as_str() {
|
||||||
|
"as_" => "as".to_string(),
|
||||||
|
_ => attribute.key.to_string(),
|
||||||
|
};
|
||||||
|
static_format.push_str(&format!(" {}", key));
|
||||||
|
|
||||||
|
match attribute.value() {
|
||||||
|
Some(Expr::Lit(ExprLit {
|
||||||
|
lit: syn::Lit::Str(value),
|
||||||
|
..
|
||||||
|
})) => {
|
||||||
|
static_format.push_str(&format!(
|
||||||
|
r#"="{}""#,
|
||||||
|
html_escape::encode_unquoted_attribute(&value.value())
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Some(Expr::Lit(ExprLit {
|
||||||
|
lit: syn::Lit::Bool(value),
|
||||||
|
..
|
||||||
|
})) => {
|
||||||
|
static_format.push_str(&format!(r#"="{}""#, value.value()));
|
||||||
|
}
|
||||||
|
Some(Expr::Lit(ExprLit {
|
||||||
|
lit: syn::Lit::Int(value),
|
||||||
|
..
|
||||||
|
})) => {
|
||||||
|
static_format.push_str(&format!(r#"="{}""#, value.token()));
|
||||||
|
}
|
||||||
|
Some(Expr::Lit(ExprLit {
|
||||||
|
lit: syn::Lit::Float(value),
|
||||||
|
..
|
||||||
|
})) => {
|
||||||
|
static_format.push_str(&format!(r#"="{}""#, value.token()));
|
||||||
|
}
|
||||||
|
Some(value) => {
|
||||||
|
static_format.push_str(r#"="{}""#);
|
||||||
|
format_value = Some(
|
||||||
|
quote! {{
|
||||||
|
// (#value).escape_attribute()
|
||||||
|
::vespid::EscapeAttribute::escape_attribute(&#value)
|
||||||
|
}}
|
||||||
|
.into_token_stream(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
(static_format, format_value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_component_tag_name(name: &str) -> bool {
|
||||||
|
name.starts_with(|c: char| c.is_ascii_uppercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CustomElement<'e> {
|
||||||
|
e: &'e NodeElement<Infallible>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'e> CustomElement<'e> {
|
||||||
|
fn new(e: &'e NodeElement<Infallible>) -> Self {
|
||||||
|
CustomElement { e }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'e> ToTokens for CustomElement<'_> {
|
||||||
|
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||||
|
let name = self.e.name();
|
||||||
|
|
||||||
|
let mut chain = vec![quote! {
|
||||||
|
::vespid::support::props_builder(&#name)
|
||||||
|
}];
|
||||||
|
|
||||||
|
let children = &self.e.children;
|
||||||
|
if !children.is_empty() {
|
||||||
|
let c = process_nodes(false, children, vec![]);
|
||||||
|
chain.push(quote! { .children(#c) });
|
||||||
|
}
|
||||||
|
|
||||||
|
chain.push({
|
||||||
|
self.e
|
||||||
|
.attributes()
|
||||||
|
.iter()
|
||||||
|
.map(|a| match a {
|
||||||
|
NodeAttribute::Block(block) => {
|
||||||
|
quote! {
|
||||||
|
.push_attr(
|
||||||
|
#[allow(unused_braces)]
|
||||||
|
#block
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
NodeAttribute::Attribute(attribute) => {
|
||||||
|
let key = &attribute.key;
|
||||||
|
let value = attribute.value().unwrap();
|
||||||
|
quote! { .#key(#value) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<proc_macro2::TokenStream>()
|
||||||
|
});
|
||||||
|
|
||||||
|
chain.push(quote! { .build() });
|
||||||
|
|
||||||
|
tokens.extend(quote! {
|
||||||
|
#name(#(#chain)*).await
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn props(input: proc_macro2::TokenStream) -> proc_macro2::TokenStream {
|
||||||
|
let props = syn::parse2::<PropsStruct>(input).unwrap();
|
||||||
|
quote! { #props }.to_token_stream()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PropsStruct {
|
||||||
|
name: syn::Ident,
|
||||||
|
item: ItemStruct,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parse for PropsStruct {
|
||||||
|
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||||
|
let item = input.parse::<ItemStruct>()?;
|
||||||
|
let name = item.ident.clone();
|
||||||
|
|
||||||
|
Ok(PropsStruct { name, item })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToTokens for PropsStruct {
|
||||||
|
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||||
|
let name = &self.name;
|
||||||
|
let item = &self.item;
|
||||||
|
|
||||||
|
let builder_name =
|
||||||
|
syn::Ident::new(&format!("{}Builder", name), proc_macro2::Span::call_site());
|
||||||
|
|
||||||
|
tokens.extend(quote! {
|
||||||
|
#[derive(::vespid::typed_builder::TypedBuilder)]
|
||||||
|
#[builder(doc, crate_module_path=::vespid::typed_builder)]
|
||||||
|
#item
|
||||||
|
|
||||||
|
impl ::vespid::support::Props for #name {
|
||||||
|
type Builder = #builder_name;
|
||||||
|
fn builder() -> Self::Builder {
|
||||||
|
#name::builder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let has_attributes = item
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.any(|field| field.ident.as_ref().unwrap().to_string() == "attributes");
|
||||||
|
|
||||||
|
if has_attributes {
|
||||||
|
tokens.extend(quote! {
|
||||||
|
impl #builder_name {
|
||||||
|
pub fn push_attr<A: std::fmt::Display>(mut self, attr: A) -> Self {
|
||||||
|
self.props.attributes.push_str(&format!("{} ", attr));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[proc_macro_attribute]
|
||||||
|
pub fn component(_attr: TokenStream, input: TokenStream) -> TokenStream {
|
||||||
|
let comp = syn::parse_macro_input!(input as ComponentFn);
|
||||||
|
quote! { #comp }.to_token_stream().into()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComponentFn {
|
||||||
|
item: syn::ItemFn,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Parse for ComponentFn {
|
||||||
|
fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> {
|
||||||
|
let item = input.parse::<syn::ItemFn>()?;
|
||||||
|
Ok(ComponentFn { item })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToTokens for ComponentFn {
|
||||||
|
fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) {
|
||||||
|
let item = &self.item;
|
||||||
|
let name = &item.sig.ident;
|
||||||
|
|
||||||
|
let (defs, args) = match item.sig.inputs.len() {
|
||||||
|
0 => {
|
||||||
|
// generate empty props
|
||||||
|
let props_name =
|
||||||
|
syn::Ident::new(&format!("{}Props", name), proc_macro2::Span::call_site());
|
||||||
|
(
|
||||||
|
props(quote! {
|
||||||
|
pub struct #props_name{}
|
||||||
|
}),
|
||||||
|
quote! { _props: #props_name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// match if there is a single arg of type #nameProps
|
||||||
|
1 if matches!(item.sig.inputs.first().unwrap(), syn::FnArg::Typed(arg) if matches!(arg.ty.as_ref(), syn::Type::Path(p) if p.path.segments.last().unwrap().ident.to_string() == format!("{}Props", name))) =>
|
||||||
|
{
|
||||||
|
let props = item.sig.inputs.first().unwrap();
|
||||||
|
(quote! {}, props.to_token_stream())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let field_defs = &item
|
||||||
|
.sig
|
||||||
|
.inputs
|
||||||
|
.clone()
|
||||||
|
.into_iter()
|
||||||
|
.map(|i| match i {
|
||||||
|
FnArg::Receiver(_) => {
|
||||||
|
panic!("receiver arguments unsupported");
|
||||||
|
}
|
||||||
|
FnArg::Typed(mut t) => {
|
||||||
|
if t.attrs.is_empty() {
|
||||||
|
t.attrs.push(parse_quote! { #[builder(setter(into))] });
|
||||||
|
}
|
||||||
|
|
||||||
|
t
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Punctuated<_, Token![,]>>();
|
||||||
|
let field_names = item
|
||||||
|
.sig
|
||||||
|
.inputs
|
||||||
|
.iter()
|
||||||
|
.map(|i| match i {
|
||||||
|
FnArg::Receiver(_) => {
|
||||||
|
panic!("receiver arguments unsupported");
|
||||||
|
}
|
||||||
|
FnArg::Typed(t) => &t.pat,
|
||||||
|
})
|
||||||
|
.collect::<Punctuated<_, Token![,]>>();
|
||||||
|
let props_name =
|
||||||
|
syn::Ident::new(&format!("{}Props", name), proc_macro2::Span::call_site());
|
||||||
|
|
||||||
|
(
|
||||||
|
props(quote! {
|
||||||
|
pub struct #props_name {
|
||||||
|
#field_defs
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
quote! { #props_name { #field_names }: #props_name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let body = &item.block;
|
||||||
|
let output = &item.sig.output;
|
||||||
|
let vis = &item.vis;
|
||||||
|
|
||||||
|
tokens.extend(quote! {
|
||||||
|
#defs
|
||||||
|
#[allow(non_snake_case)]
|
||||||
|
#vis async fn #name(#args) #output {
|
||||||
|
#body
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
26
vespid/src/axum_compat.rs
Normal file
26
vespid/src/axum_compat.rs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
use axum::response::Html;
|
||||||
|
use std::{future::Future, sync::OnceLock, thread::available_parallelism};
|
||||||
|
use tokio_util::task::LocalPoolHandle;
|
||||||
|
|
||||||
|
use crate::context;
|
||||||
|
|
||||||
|
fn get_rendering_pool() -> LocalPoolHandle {
|
||||||
|
static LOCAL_POOL: OnceLock<LocalPoolHandle> = OnceLock::new();
|
||||||
|
LOCAL_POOL
|
||||||
|
.get_or_init(|| LocalPoolHandle::new(available_parallelism().map(Into::into).unwrap_or(1)))
|
||||||
|
.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn render<F, O>(f: F) -> Html<O>
|
||||||
|
where
|
||||||
|
F: Future<Output = O> + Send + 'static,
|
||||||
|
O: Send + 'static,
|
||||||
|
{
|
||||||
|
get_rendering_pool()
|
||||||
|
.spawn_pinned(move || async {
|
||||||
|
let h = context::spawn_local(f).await.unwrap();
|
||||||
|
Html(h)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
52
vespid/src/context.rs
Normal file
52
vespid/src/context.rs
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
use std::{
|
||||||
|
any::{Any, TypeId},
|
||||||
|
cell::RefCell,
|
||||||
|
collections::HashMap,
|
||||||
|
future::Future,
|
||||||
|
};
|
||||||
|
|
||||||
|
tokio::task_local! {
|
||||||
|
pub(crate) static CONTEXT: RefCell<HashMap<TypeId, Box<dyn Any>>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn_local<F: Future<Output = O> + 'static, O: 'static>(
|
||||||
|
fut: F,
|
||||||
|
) -> tokio::task::JoinHandle<O> {
|
||||||
|
tokio::task::spawn_local(async move { CONTEXT.scope(RefCell::new(HashMap::new()), fut).await })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provide_context<T: 'static>(value: T) {
|
||||||
|
let _ = CONTEXT.with(|context| {
|
||||||
|
context
|
||||||
|
.borrow_mut()
|
||||||
|
.insert(TypeId::of::<T>(), Box::new(value));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_context<T>() -> Option<T>
|
||||||
|
where
|
||||||
|
T: Clone + 'static,
|
||||||
|
{
|
||||||
|
CONTEXT.with(|context| {
|
||||||
|
context
|
||||||
|
.borrow()
|
||||||
|
.get(&TypeId::of::<T>())
|
||||||
|
.map(|any| {
|
||||||
|
any.downcast_ref::<T>().unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"Context type mismatch for {T}",
|
||||||
|
T = std::any::type_name::<T>()
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expect_context<T>() -> T
|
||||||
|
where
|
||||||
|
T: Clone + 'static,
|
||||||
|
{
|
||||||
|
use_context::<T>()
|
||||||
|
.unwrap_or_else(|| panic!("Context not found for {T}", T = std::any::type_name::<T>()))
|
||||||
|
}
|
47
vespid/src/escape_attribute.rs
Normal file
47
vespid/src/escape_attribute.rs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
|
use html_escape::encode_unquoted_attribute;
|
||||||
|
|
||||||
|
pub trait EscapeAttribute {
|
||||||
|
fn escape_attribute(&self) -> Cow<str>;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EscapeAttribute for &str {
|
||||||
|
fn escape_attribute(&self) -> Cow<str> {
|
||||||
|
encode_unquoted_attribute(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EscapeAttribute for str {
|
||||||
|
fn escape_attribute(&self) -> Cow<str> {
|
||||||
|
encode_unquoted_attribute(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EscapeAttribute for String {
|
||||||
|
fn escape_attribute(&self) -> Cow<str> {
|
||||||
|
encode_unquoted_attribute(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EscapeAttribute for &String {
|
||||||
|
fn escape_attribute(&self) -> Cow<str> {
|
||||||
|
encode_unquoted_attribute(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_escape_attribute_literal {
|
||||||
|
($($t:ty),*) => {
|
||||||
|
$(
|
||||||
|
impl EscapeAttribute for $t {
|
||||||
|
fn escape_attribute(&self) -> Cow<str> {
|
||||||
|
Cow::Owned(self.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_escape_attribute_literal!(
|
||||||
|
u8, u16, u32, u64, u128, usize, i8, i16, i32, i64, i128, isize, f32, f64, bool
|
||||||
|
);
|
24
vespid/src/lib.rs
Normal file
24
vespid/src/lib.rs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
mod escape_attribute;
|
||||||
|
pub use escape_attribute::*;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub mod support;
|
||||||
|
pub use support::*;
|
||||||
|
|
||||||
|
mod render;
|
||||||
|
pub use render::Render;
|
||||||
|
|
||||||
|
mod render_adapter;
|
||||||
|
pub use render_adapter::*;
|
||||||
|
|
||||||
|
mod context;
|
||||||
|
pub use context::*;
|
||||||
|
|
||||||
|
pub mod axum_compat;
|
||||||
|
|
||||||
|
pub use vespid_macros::*;
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub extern crate typed_builder;
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub extern crate html_escape;
|
142
vespid/src/render.rs
Normal file
142
vespid/src/render.rs
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
use std::{
|
||||||
|
borrow::Cow,
|
||||||
|
convert::Infallible,
|
||||||
|
env::VarError,
|
||||||
|
fmt,
|
||||||
|
io::ErrorKind,
|
||||||
|
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6},
|
||||||
|
num::{
|
||||||
|
NonZeroI128, NonZeroI16, NonZeroI32, NonZeroI64, NonZeroI8, NonZeroIsize, NonZeroU128,
|
||||||
|
NonZeroU16, NonZeroU32, NonZeroU64, NonZeroU8, NonZeroUsize,
|
||||||
|
},
|
||||||
|
rc::Rc,
|
||||||
|
sync::{
|
||||||
|
mpsc::{RecvTimeoutError, TryRecvError},
|
||||||
|
Arc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub trait Render {
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! impl_render_for_basic_types {
|
||||||
|
($($t:ty)*) => ($(
|
||||||
|
impl Render for $t {
|
||||||
|
#[inline(always)]
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
fmt::Display::fmt(self, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)*)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl_render_for_basic_types! {
|
||||||
|
Infallible
|
||||||
|
VarError
|
||||||
|
ErrorKind
|
||||||
|
IpAddr
|
||||||
|
SocketAddr
|
||||||
|
RecvTimeoutError
|
||||||
|
TryRecvError
|
||||||
|
bool
|
||||||
|
char
|
||||||
|
f32
|
||||||
|
f64
|
||||||
|
i8
|
||||||
|
i16
|
||||||
|
i32
|
||||||
|
i64
|
||||||
|
i128
|
||||||
|
isize
|
||||||
|
u8
|
||||||
|
u16
|
||||||
|
u32
|
||||||
|
u64
|
||||||
|
u128
|
||||||
|
usize
|
||||||
|
Ipv4Addr
|
||||||
|
Ipv6Addr
|
||||||
|
SocketAddrV4
|
||||||
|
SocketAddrV6
|
||||||
|
NonZeroI8
|
||||||
|
NonZeroI16
|
||||||
|
NonZeroI32
|
||||||
|
NonZeroI64
|
||||||
|
NonZeroI128
|
||||||
|
NonZeroIsize
|
||||||
|
NonZeroU8
|
||||||
|
NonZeroU16
|
||||||
|
NonZeroU32
|
||||||
|
NonZeroU64
|
||||||
|
NonZeroU128
|
||||||
|
NonZeroUsize
|
||||||
|
String
|
||||||
|
str
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<B: ?Sized> Render for Cow<'_, B>
|
||||||
|
where
|
||||||
|
B: Render + ToOwned,
|
||||||
|
<B as ToOwned>::Owned: Render,
|
||||||
|
{
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match *self {
|
||||||
|
Cow::Borrowed(ref b) => Render::render(b, f),
|
||||||
|
Cow::Owned(ref o) => Render::render(o, f),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Render> Render for Box<T> {
|
||||||
|
#[inline(always)]
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
Render::render(&**self, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Render + ?Sized> Render for &T {
|
||||||
|
#[inline(always)]
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
Render::render(&**self, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Render> Render for &mut T {
|
||||||
|
#[inline(always)]
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
Render::render(&**self, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Render> Render for Rc<T> {
|
||||||
|
#[inline(always)]
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
Render::render(&**self, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Render> Render for Arc<T> {
|
||||||
|
#[inline(always)]
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
Render::render(&**self, f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Render> Render for Option<T> {
|
||||||
|
#[inline(always)]
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
if let Some(inner) = self {
|
||||||
|
inner.render(f)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for () {
|
||||||
|
#[inline(always)]
|
||||||
|
fn render(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
33
vespid/src/render_adapter.rs
Normal file
33
vespid/src/render_adapter.rs
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
use core::fmt;
|
||||||
|
|
||||||
|
use crate::Render;
|
||||||
|
|
||||||
|
pub struct RenderDisplay<T>(pub T);
|
||||||
|
|
||||||
|
impl<T: core::fmt::Display> Render for RenderDisplay<T> {
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RenderDebug<T>(pub T);
|
||||||
|
|
||||||
|
impl<T: core::fmt::Debug> Render for RenderDebug<T> {
|
||||||
|
fn render(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.fmt(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct FormatRender<T>(pub T);
|
||||||
|
|
||||||
|
impl<T> FormatRender<T> {
|
||||||
|
pub fn new(value: T) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: Render> fmt::Display for FormatRender<T> {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
self.0.render(f)
|
||||||
|
}
|
||||||
|
}
|
21
vespid/src/support.rs
Normal file
21
vespid/src/support.rs
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
pub trait Component<P> {}
|
||||||
|
|
||||||
|
impl<P, F, R> Component<P> for F
|
||||||
|
where
|
||||||
|
F: FnOnce(P) -> R,
|
||||||
|
P: Props,
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn props_builder<C, P>(_: C) -> P::Builder
|
||||||
|
where
|
||||||
|
C: Component<P>,
|
||||||
|
P: Props,
|
||||||
|
{
|
||||||
|
P::builder()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Props {
|
||||||
|
type Builder;
|
||||||
|
fn builder() -> Self::Builder;
|
||||||
|
}
|
Loading…
Reference in a new issue