lib.rs
1 use std::collections::HashMap; 2 use std::path::Path; 3 use std::path::PathBuf; 4 5 use anyhow::Context; 6 use convert_case::{Case, Casing}; 7 use mdbook_shared::MdBook; 8 use proc_macro2::Ident; 9 use proc_macro2::Span; 10 use proc_macro2::TokenStream as TokenStream2; 11 use quote::format_ident; 12 use quote::quote; 13 use quote::ToTokens; 14 use syn::LitStr; 15 16 use crate::transform_book::write_book_with_routes; 17 18 mod rsx; 19 mod transform_book; 20 21 pub fn make_docs_from_ws(version: &str) { 22 let mdbook_dir = PathBuf::from("../../docs-src").join(version); 23 let out_dir = std::env::current_dir().unwrap().join("src"); 24 let mut out = generate_router_build_script(mdbook_dir); 25 out.push_str("use dioxus_docs_examples::*;\n"); 26 out.push_str("use dioxus::prelude::*;\n"); 27 let version_flattened = version.replace(".", ""); 28 let filename = format!("docsgen.rs"); 29 std::fs::write(out_dir.join(filename), out).unwrap(); 30 } 31 32 /// Generate the contents of the mdbook from a router 33 pub fn generate_router_build_script(mdbook_dir: PathBuf) -> String { 34 let file_src = generate_router_as_file(mdbook_dir.clone(), MdBook::new(mdbook_dir).unwrap()); 35 prettyplease::unparse(&file_src) 36 } 37 38 /// Load an mdbook from the filesystem using the target tokens 39 /// ```ignore 40 /// 41 /// 42 /// ``` 43 pub fn load_book_from_fs( 44 input: LitStr, 45 ) -> anyhow::Result<(PathBuf, mdbook_shared::MdBook<PathBuf>)> { 46 let user_dir = input.value().parse::<PathBuf>()?; 47 let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?); 48 let path = manifest_dir.join(user_dir); 49 let path = path.canonicalize().with_context(|| { 50 anyhow::anyhow!( 51 "Failed to canonicalize the path to the book at {:?}", 52 path.display() 53 ) 54 })?; 55 56 Ok((path.clone(), MdBook::new(path)?)) 57 } 58 59 pub fn generate_router_as_file( 60 mdbook_dir: PathBuf, 61 book: mdbook_shared::MdBook<PathBuf>, 62 ) -> syn::File { 63 let router = generate_router(mdbook_dir, book); 64 65 syn::parse_quote! { 66 #router 67 } 68 } 69 70 pub fn generate_router(mdbook_dir: PathBuf, book: mdbook_shared::MdBook<PathBuf>) -> TokenStream2 { 71 let mdbook = write_book_with_routes(&book); 72 73 let mut page_markdown_map = HashMap::new(); 74 75 let book_pages = book.pages().iter().map(|(_, page)| { 76 let name = path_to_route_variant(&page.url).unwrap(); 77 78 // Rsx doesn't work very well in macros because the path for all the routes generated point to the same characters. We manually expand rsx here to get around that issue. 79 match rsx::parse_markdown(mdbook_dir.clone(), page.url.clone(), &page.raw) { 80 Ok(parsed) => { 81 // insert the parsed markdown into the page_markdown map 82 page_markdown_map.insert(page.id.0, parsed.resolved_markdown); 83 84 // for the sake of readability, we want to actually convert the CallBody back to Tokens 85 let rsx = rsx::callbody_to_tokens(parsed.body); 86 87 // Create the fragment enum for the section 88 let section_enum = path_to_route_section(&page.url).unwrap(); 89 let mut error_message = format!("Invalid section name. Expected one of {}", section_enum); 90 for (i, section) in parsed.sections.iter().enumerate() { 91 if i > 0 { 92 error_message.push_str(", "); 93 } 94 error_message.push_str(§ion.fragment()); 95 } 96 let section_idents: Vec<_> = parsed 97 .sections 98 .iter() 99 .filter_map(|section| Some(Ident::new(§ion.variant().ok()?, Span::call_site()))) 100 .collect(); 101 let section_names: Vec<_> = parsed 102 .sections 103 .iter() 104 .map(|section| section.fragment()) 105 .collect(); 106 let fragment = quote! { 107 #[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default, serde::Serialize, serde::Deserialize)] 108 pub enum #section_enum { 109 #[default] 110 Empty, 111 #(#section_idents),* 112 } 113 114 impl std::str::FromStr for #section_enum { 115 type Err = &'static str; 116 117 fn from_str(s: &str) -> Result<Self, Self::Err> { 118 match s { 119 "" => Ok(Self::Empty), 120 #( 121 #section_names => Ok(Self::#section_idents), 122 )* 123 _ => Err(#error_message) 124 } 125 } 126 } 127 128 impl std::fmt::Display for #section_enum { 129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 130 match self { 131 Self::Empty => f.write_str(""), 132 #( 133 Self::#section_idents => f.write_str(#section_names), 134 )* 135 } 136 } 137 } 138 }; 139 140 quote! { 141 #fragment 142 143 #[component(no_case_check)] 144 pub fn #name(section: #section_enum) -> Element { 145 rsx! { 146 #rsx 147 } 148 } 149 } 150 } 151 Err(err) => err.to_compile_error(), 152 } 153 }); 154 let book_pages = quote! {#(#book_pages)*}; 155 156 let default_impl = book 157 .pages() 158 .iter() 159 .min_by_key(|(_, page)| page.url.to_string_lossy().len()) 160 .map(|(_, page)| { 161 let name = path_to_route_enum(&page.url).unwrap(); 162 quote! { 163 impl Default for BookRoute { 164 fn default() -> Self { 165 #name 166 } 167 } 168 } 169 }); 170 171 let book_routes = book.pages().iter().map(|(_, page)| { 172 let name = path_to_route_variant(&page.url).unwrap(); 173 let section = path_to_route_section(&page.url).unwrap(); 174 let route_without_extension = page.url.with_extension(""); 175 // remove any trailing "index" 176 let route_without_extension = route_without_extension.to_string_lossy().to_string(); 177 let mut url = route_without_extension; 178 if let Some(stripped) = url.strip_suffix("index") { 179 url = stripped.to_string(); 180 } 181 if !url.starts_with('/') { 182 url = format!("/{}", url); 183 } 184 url += "#:section"; 185 quote! { 186 #[route(#url)] 187 #name { 188 section: #section 189 }, 190 } 191 }); 192 193 let match_page_id = book.pages().iter().map(|(_, page)| { 194 let id = page.id.0; 195 let variant = path_to_route_variant(&page.url).unwrap(); 196 quote! { 197 BookRoute::#variant { .. } => use_mdbook::mdbook_shared::PageId(#id), 198 } 199 }); 200 201 let page_markdown = { 202 let match_page = page_markdown_map.iter().map(|(id, markdown)| { 203 let id = *id; 204 quote! { 205 #id => #markdown, 206 } 207 }); 208 209 quote! { 210 /// Get the markdown for a page by its ID 211 pub const fn page_markdown(id: use_mdbook::mdbook_shared::PageId) -> &'static str { 212 match id.0 { 213 #( 214 #match_page 215 )* 216 _ => { 217 panic!("Invalid page ID:") 218 } 219 } 220 } 221 } 222 }; 223 224 quote! { 225 #[derive(Clone, Copy, dioxus_router::Routable, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)] 226 pub enum BookRoute { 227 #(#book_routes)* 228 } 229 230 impl BookRoute { 231 #page_markdown 232 233 pub fn sections(&self) -> &'static [use_mdbook::mdbook_shared::Section] { 234 &self.page().sections 235 } 236 237 pub fn page(&self) -> &'static use_mdbook::mdbook_shared::Page<Self> { 238 LAZY_BOOK.get_page(self) 239 } 240 241 pub fn page_id(&self) -> use_mdbook::mdbook_shared::PageId { 242 match self { 243 #( 244 #match_page_id 245 )* 246 } 247 } 248 } 249 250 #default_impl 251 252 pub static LAZY_BOOK: use_mdbook::Lazy<use_mdbook::mdbook_shared::MdBook<BookRoute>> = use_mdbook::Lazy::new(|| { 253 #mdbook 254 }); 255 256 #book_pages 257 } 258 } 259 260 pub(crate) fn path_to_route_variant_name(path: &Path) -> Result<String, EmptyIdentError> { 261 let path_without_extension = path.with_extension(""); 262 let mut title = String::new(); 263 for segment in path_without_extension.components() { 264 title.push(' '); 265 title.push_str(&segment.as_os_str().to_string_lossy()); 266 } 267 let filtered = title 268 .chars() 269 .filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '_') 270 .collect::<String>(); 271 272 to_upper_camel_case_for_ident(&filtered) 273 } 274 275 /// Convert a string to an upper camel case which will be a valid Rust identifier. Any leading numbers will be skipped. 276 pub(crate) fn to_upper_camel_case_for_ident(title: &str) -> Result<String, EmptyIdentError> { 277 let upper = title.to_case(Case::UpperCamel); 278 Ok( 279 if upper.chars().next().ok_or(EmptyIdentError)?.is_numeric() { 280 format!("_{}", upper) 281 } else { 282 upper 283 }, 284 ) 285 } 286 287 #[derive(Debug)] 288 pub(crate) struct EmptyIdentError; 289 290 impl ToTokens for EmptyIdentError { 291 fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { 292 let err = self.to_string(); 293 tokens.extend(quote! { 294 compile_error!(#err) 295 }) 296 } 297 } 298 299 impl std::error::Error for EmptyIdentError {} 300 301 impl std::fmt::Display for EmptyIdentError { 302 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 303 f.write_str("Empty identifiers are not allowed") 304 } 305 } 306 307 pub(crate) fn path_to_route_variant(path: &Path) -> Result<Ident, EmptyIdentError> { 308 let title = path_to_route_variant_name(path)?; 309 Ok(Ident::new(&title, Span::call_site())) 310 } 311 312 pub(crate) fn path_to_route_section(path: &Path) -> Result<Ident, EmptyIdentError> { 313 let title = path_to_route_variant_name(path)?; 314 Ok(Ident::new(&format!("{}Section", title), Span::call_site())) 315 } 316 317 pub(crate) fn path_to_route_enum(path: &Path) -> Result<TokenStream2, EmptyIdentError> { 318 path_to_route_enum_with_section(path, Ident::new("Empty", Span::call_site())) 319 } 320 321 pub(crate) fn path_to_route_enum_with_section( 322 path: &Path, 323 section_variant: Ident, 324 ) -> Result<TokenStream2, EmptyIdentError> { 325 let name = path_to_route_variant(path)?; 326 let section = path_to_route_section(path)?; 327 Ok(quote! { 328 BookRoute::#name { 329 section: #section::#section_variant 330 } 331 }) 332 } 333 334 fn rustfmt_via_cli(input: &str) -> String { 335 let tmpfile = std::env::temp_dir().join(format!("mdbook-gen-{}.rs", std::process::id())); 336 std::fs::write(&tmpfile, input).unwrap(); 337 338 let file = std::fs::File::open(&tmpfile).unwrap(); 339 let output = std::process::Command::new("rustfmt") 340 .arg("--edition=2021") 341 .stdin(file) 342 .stdout(std::process::Stdio::piped()) 343 .output() 344 .unwrap(); 345 346 _ = std::fs::remove_file(tmpfile); 347 348 String::from_utf8(output.stdout).unwrap() 349 }