use proc_macro::TokenStream; use quote::quote; use syn::{ Expr, Ident, LitStr, Result, Token, braced, parse::{Parse, ParseStream}, parse_macro_input, }; /// A macro for generating static asset handlers with compile-time embedding. /// /// This macro generates: /// - Individual structs for each asset that implement the `assetserver::Asset` trait /// - Compile-time embedding of asset files using `include_bytes!` /// - Optionally, an Axum router function that serves all assets /// /// # Syntax /// /// ```notrust /// assets! { /// prefix: "/assets" /// router: router_function_name() /// /// AssetName { /// source: "path/to/file.ext", /// target: "/public-url.ext" /// } /// /// AnotherAsset { /// source: "path/to/another.ext", /// target: "/another-url.ext" /// } /// } /// ``` /// /// # Arguments /// /// - `prefix`: The URL prefix for all assets (e.g., "/assets") /// - `router`: (Optional) The name of a function to generate that returns `(&'static str, Router<()>)` /// with routes for all assets /// - Asset blocks: Each block defines an asset with: /// - A name (identifier) for the generated struct /// - `source`: The file system path to the asset (relative to the current file) /// - `target`: The URL path where the asset will be served /// /// # Generated Code /// /// For each asset, the macro generates: /// - A struct with the specified name /// - An `assetserver::Asset` trait implementation containing: /// - `URL_PREFIX`: The common prefix for all assets /// - `URL`: The specific URL path for this asset /// - `BYTES`: The embedded file contents as a byte slice /// - Documentation showing the original asset definition /// /// If `router` is specified, also generates a function that returns an Axum router /// with all assets mounted at their target URLs. /// /// # Example /// /// ```notrust /// assets! { /// prefix: "/static" /// router: static_router() /// /// Logo { /// source: "../images/logo.png", /// target: "/logo.png" /// } /// } /// ``` /// /// This generates structs implementing `assetserver::Asset` and optionally a router function: /// /// ```notrust /// pub fn static_router() -> (&'static str, ::axum::Router<()>) { /// let router = ::axum::Router::new() /// .route(Logo::URL, ::axum::routing::get(|| async { /// (::axum::http::StatusCode::OK, Logo::BYTES) /// })); /// ("/static", router) /// } /// ``` #[proc_macro] pub fn assets(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as AssetsInput); let prefix = &input.prefix; let asset_impls = input.assets.iter().map(|asset| { let name = &asset.name; let source = &asset.source; let target = &asset.target; // Generate documentation showing the original asset definition let doc = format!( "This is an `asset!`\n```notrust\n{} {{\n\tsource: \"{:?}\",\n\ttarget: \"{}\"\n}}\n```", name, source, target ); quote! { #[expect(clippy::allow_attributes)] #[allow(non_camel_case_types)] #[doc = #doc] pub struct #name {} impl ::assetserver::Asset for #name { const URL_PREFIX: &'static str = #prefix; const URL_POSTFIX: &'static str = #target; const URL: &'static str = concat!(#prefix, #target); const BYTES: &'static [u8] = #source; } } }); // Generate the router function if specified let router_fn = if let Some(router_name) = &input.router { let route_definitions = input.assets.iter().map(|asset| { let name = &asset.name; let headers = asset .headers .as_ref() .map(|x| quote! { #x }) .unwrap_or(quote! { [] }); quote! { .route(#name::URL_POSTFIX, ::axum::routing::get(|| async { ( ::axum::http::StatusCode::OK, #headers, #name::BYTES ) })) } }); let router_doc = format!( "Generated router function that serves {} asset(s) with prefix \"{}\"", input.assets.len(), prefix ); quote! { #[doc = #router_doc] pub fn #router_name() -> (&'static str, ::axum::Router<()>) { use ::tower_http::compression::{CompressionLayer, DefaultPredicate}; let compression: CompressionLayer = CompressionLayer::new() .br(true) .deflate(true) .gzip(true) .zstd(true) .compress_when(DefaultPredicate::new()); let router = ::axum::Router::new() #(#route_definitions)* .layer(compression); (#prefix, router) } } } else { quote! {} }; let expanded = quote! { #(#asset_impls)* #router_fn }; TokenStream::from(expanded) } /// Represents the complete input to the `assets!` macro struct AssetsInput { prefix: String, router: Option, assets: Vec, } /// Represents a single asset definition within the macro struct AssetDefinition { name: Ident, source: Expr, target: String, headers: Option, } impl Parse for AssetsInput { fn parse(input: ParseStream<'_>) -> Result { // Parse "prefix:" let _prefix_ident: Ident = input.parse()?; let _colon: Token![:] = input.parse()?; let prefix_lit: LitStr = input.parse()?; let prefix = prefix_lit.value(); // Try to parse optional "router:" parameter let router = if input.peek(Ident) { let peek_ident: Ident = input.fork().parse()?; if peek_ident == "router" { let _router_ident: Ident = input.parse()?; let _colon: Token![:] = input.parse()?; let router_name: Ident = input.parse()?; // Parse the parentheses after the function name let _paren_content; syn::parenthesized!(_paren_content in input); Some(router_name) } else { None } } else { None }; let mut assets = Vec::new(); // Parse asset definitions until we reach the end while !input.is_empty() { let asset = input.parse::()?; assets.push(asset); } Ok(AssetsInput { prefix, router, assets, }) } } impl Parse for AssetDefinition { fn parse(input: ParseStream<'_>) -> Result { // Parse the asset name let name: Ident = input.parse()?; // Parse the braced content let content; braced!(content in input); // Parse fields in any order let mut source: Option = None; let mut target: Option = None; let mut headers: Option = None; while !content.is_empty() { // Parse field name let field_name: Ident = content.parse()?; let _colon: Token![:] = content.parse()?; // Parse field value based on name match field_name.to_string().as_str() { "source" => { if source.is_some() { return Err(syn::Error::new( field_name.span(), "duplicate 'source' field", )); } source = Some(content.parse()?); } "target" => { if target.is_some() { return Err(syn::Error::new( field_name.span(), "duplicate 'target' field", )); } let target_lit: LitStr = content.parse()?; target = Some(target_lit.value()); } "headers" => { if headers.is_some() { return Err(syn::Error::new( field_name.span(), "duplicate 'headers' field", )); } headers = Some(content.parse()?); } _ => { return Err(syn::Error::new( field_name.span(), format!( "unknown field '{}', expected 'source', 'target', or 'headers'", field_name ), )); } } // Parse comma if not at end if !content.is_empty() { content.parse::()?; } } // Validate required fields let source = source .ok_or_else(|| syn::Error::new(name.span(), "missing required field 'source'"))?; let target = target .ok_or_else(|| syn::Error::new(name.span(), "missing required field 'target'"))?; Ok(AssetDefinition { name, source, target, headers, }) } }