/ libs / mdbook-gen / src / lib.rs
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(&section.fragment());
 95                  }
 96                  let section_idents: Vec<_> = parsed
 97                      .sections
 98                      .iter()
 99                      .filter_map(|section| Some(Ident::new(&section.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  }