From e84e77dff9ce88f7fd139060610b374abcf7d760 Mon Sep 17 00:00:00 2001 From: rm-dr <96270320+rm-dr@users.noreply.github.com> Date: Sat, 1 Nov 2025 21:40:55 -0700 Subject: [PATCH] Added `assetserver` and macro --- crates/assetserver/Cargo.toml | 8 ++ crates/assetserver/src/lib.rs | 16 +++ crates/macro-assets/Cargo.toml | 15 +++ crates/macro-assets/src/lib.rs | 239 +++++++++++++++++++++++++++++++++ 4 files changed, 278 insertions(+) create mode 100644 crates/assetserver/Cargo.toml create mode 100644 crates/assetserver/src/lib.rs create mode 100644 crates/macro-assets/Cargo.toml create mode 100644 crates/macro-assets/src/lib.rs diff --git a/crates/assetserver/Cargo.toml b/crates/assetserver/Cargo.toml new file mode 100644 index 0000000..ebcffd2 --- /dev/null +++ b/crates/assetserver/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "assetserver" +version = { workspace = true } +rust-version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true diff --git a/crates/assetserver/src/lib.rs b/crates/assetserver/src/lib.rs new file mode 100644 index 0000000..b5ebd62 --- /dev/null +++ b/crates/assetserver/src/lib.rs @@ -0,0 +1,16 @@ +/// A static asset with compile-time embedded data. +pub trait Asset { + /// The common URL prefix for all assets (e.g., "/assets") + const URL_PREFIX: &'static str; + + /// The specific URL path for this asset (e.g., "/logo.png") + const URL_POSTFIX: &'static str; + + /// The full URL for this asset (e.g., "/assets/logo.png") + const URL: &'static str; + + /// The embedded file contents as a byte slice + const BYTES: &'static [u8]; +} + +// TODO: image manipulation diff --git a/crates/macro-assets/Cargo.toml b/crates/macro-assets/Cargo.toml new file mode 100644 index 0000000..025e8f9 --- /dev/null +++ b/crates/macro-assets/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "macro-assets" +version = { workspace = true } +rust-version = { workspace = true } +edition = { workspace = true } + +[lints] +workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { workspace = true } +quote = { workspace = true } diff --git a/crates/macro-assets/src/lib.rs b/crates/macro-assets/src/lib.rs new file mode 100644 index 0000000..bb57a6f --- /dev/null +++ b/crates/macro-assets/src/lib.rs @@ -0,0 +1,239 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{ + 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] = include_bytes!(#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; + quote! { + .route(#name::URL_POSTFIX, ::axum::routing::get(|| async { + (::axum::http::StatusCode::OK, #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<()>) { + let router = ::axum::Router::new() + #(#route_definitions)*; + (#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: String, + target: String, +} + +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 "source:" + let _source_ident: Ident = content.parse()?; + let _colon1: Token![:] = content.parse()?; + let source_lit: LitStr = content.parse()?; + let source = source_lit.value(); + let _comma1: Token![,] = content.parse()?; + + // Parse "target:" + let _target_ident: Ident = content.parse()?; + let _colon2: Token![:] = content.parse()?; + let target_lit: LitStr = content.parse()?; + let target = target_lit.value(); + + // Optional trailing comma + let _ = content.parse::(); + + Ok(AssetDefinition { + name, + source, + target, + }) + } +}