at main 422 lines 14 kB view raw
1//! Static renderer 2//! 3//! This mode of the renderer creates a static html and css website from a notebook in a local directory. 4//! It does not upload it to the PDS by default (though it can ). This is good for testing and for self-hosting. 5//! URLs in the notebook are mostly unaltered. It is compatible with GitHub or Cloudflare Pages 6//! and other similar static hosting services. 7 8pub mod context; 9pub mod document; 10pub mod writer; 11 12use crate::utils::VaultBrokenLinkCallback; 13use crate::{ 14 ContextIterator, NotebookProcessor, 15 static_site::{ 16 context::StaticSiteContext, 17 document::{CssMode, write_document_footer, write_document_head}, 18 writer::StaticPageWriter, 19 }, 20 theme::default_resolved_theme, 21 utils::flatten_dir_to_just_one_parent, 22 walker::{WalkOptions, vault_contents}, 23}; 24use bitflags::bitflags; 25use markdown_weaver::{BrokenLink, CowStr, Parser}; 26use markdown_weaver_escape::FmtWriter; 27use miette::IntoDiagnostic; 28#[cfg(all(target_family = "wasm", target_os = "unknown"))] 29use n0_future::io::AsyncWriteExt; 30use std::{ 31 path::{Path, PathBuf}, 32 sync::Arc, 33}; 34#[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 35use tokio::io::AsyncWriteExt; 36use unicode_normalization::UnicodeNormalization; 37use weaver_common::jacquard::{client::AgentSession, prelude::*}; 38 39bitflags! { 40 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] 41 pub struct StaticSiteOptions:u32 { 42 const FLATTEN_STRUCTURE = 1 << 1; 43 const UPLOAD_BLOBS = 1 << 2; 44 const INLINE_EMBEDS = 1 << 3; 45 const ADD_LINK_PREVIEWS = 1 << 4; 46 const RESOLVE_AT_IDENTIFIERS = 1 << 5; 47 const RESOLVE_AT_URIS = 1 << 6; 48 const ADD_BSKY_COMMENTS_EMBED = 1 << 7; 49 const CREATE_INDEX = 1 << 8; 50 const CREATE_CHAPTERS_BY_DIRECTORY = 1 << 9; 51 const CREATE_PAGES_BY_TITLE = 1 << 10; 52 const NORMALIZE_DIR_NAMES = 1 << 11; 53 const ADD_TOC_TO_PAGES = 1 << 12; 54 } 55} 56 57impl Default for StaticSiteOptions { 58 fn default() -> Self { 59 Self::FLATTEN_STRUCTURE 60 //| Self::UPLOAD_BLOBS 61 | Self::RESOLVE_AT_IDENTIFIERS 62 | Self::RESOLVE_AT_URIS 63 | Self::CREATE_INDEX 64 | Self::CREATE_CHAPTERS_BY_DIRECTORY 65 | Self::CREATE_PAGES_BY_TITLE 66 | Self::NORMALIZE_DIR_NAMES 67 } 68} 69 70pub struct StaticSiteWriter<A> 71where 72 A: AgentSession, 73{ 74 context: StaticSiteContext<A>, 75} 76 77impl<A> StaticSiteWriter<A> 78where 79 A: AgentSession, 80{ 81 pub fn new(root: PathBuf, destination: PathBuf, session: Option<A>) -> Self { 82 let context = StaticSiteContext::new(root, destination, session); 83 Self { context } 84 } 85} 86 87impl<A> StaticSiteWriter<A> 88where 89 A: AgentSession + IdentityResolver + 'static, 90{ 91 pub async fn run(mut self) -> Result<(), miette::Report> { 92 if !self.context.root.exists() { 93 return Err(miette::miette!( 94 "The path specified ({}) does not exist", 95 self.context.root.display() 96 )); 97 } 98 let contents = vault_contents(&self.context.root, WalkOptions::new())?; 99 100 self.context.dir_contents = Some(contents.into()); 101 102 if self.context.root.is_file() || self.context.start_at.is_file() { 103 let source_filename = self 104 .context 105 .start_at 106 .file_name() 107 .expect("wtf how!?") 108 .to_string_lossy(); 109 110 let dest = if self.context.destination.is_dir() { 111 self.context.destination.join(String::from(source_filename)) 112 } else { 113 let parent = self 114 .context 115 .destination 116 .parent() 117 .unwrap_or(&self.context.destination); 118 // Avoid recursively creating self.destination through the call to 119 // export_note when the parent directory doesn't exist. 120 if !parent.exists() { 121 return Err(miette::miette!( 122 "Destination parent path ({}) does not exist, SOMEHOW", 123 parent.display() 124 )); 125 } 126 self.context.destination.clone() 127 }; 128 // Use standalone writer for single file (inline CSS) 129 return write_page_standalone(self.context.clone(), &self.context.start_at, dest).await; 130 } 131 132 if !self.context.destination.exists() { 133 return Err(miette::miette!( 134 "The destination path specified ({}) does not exist", 135 self.context.destination.display() 136 )); 137 } 138 139 // Generate CSS files for multi-file mode 140 self.generate_css_files().await?; 141 142 for file in self 143 .context 144 .dir_contents 145 .as_ref() 146 .unwrap() 147 .clone() 148 .into_iter() 149 .filter(|file| file.starts_with(&self.context.start_at)) 150 { 151 let context = self.context.clone(); 152 let relative_path = file 153 .strip_prefix(context.start_at.clone()) 154 .expect("file should always be nested under root") 155 .to_path_buf(); 156 157 // Check if this is a markdown file 158 let is_markdown = file 159 .extension() 160 .and_then(|ext| ext.to_str()) 161 .map(|ext| ext == "md" || ext == "markdown") 162 .unwrap_or(false); 163 164 if !is_markdown { 165 // Copy non-markdown files directly 166 let output_path = if context 167 .options 168 .contains(StaticSiteOptions::FLATTEN_STRUCTURE) 169 { 170 let path_str = relative_path.to_string_lossy(); 171 let (parent, fname) = flatten_dir_to_just_one_parent(&path_str); 172 let parent = if parent.is_empty() { "entry" } else { parent }; 173 context 174 .destination 175 .join(String::from(parent)) 176 .join(String::from(fname)) 177 } else { 178 context.destination.join(relative_path.clone()) 179 }; 180 181 // Create parent directory if needed 182 if let Some(parent) = output_path.parent() { 183 tokio::fs::create_dir_all(parent).await.into_diagnostic()?; 184 } 185 186 tokio::fs::copy(&file, &output_path) 187 .await 188 .into_diagnostic()?; 189 return Ok(()); 190 } 191 192 // Process markdown files 193 // Check if this is the designated index file 194 if let Some(index) = &context.index_file { 195 if &relative_path == index { 196 let output_path = context.destination.join("index.html"); 197 return write_page(context.clone(), file, output_path).await; 198 } 199 } 200 201 if context 202 .options 203 .contains(StaticSiteOptions::FLATTEN_STRUCTURE) 204 { 205 let path_str = relative_path.to_string_lossy(); 206 let (parent, fname) = flatten_dir_to_just_one_parent(&path_str); 207 let parent = if parent.is_empty() { "entry" } else { parent }; 208 let output_path = context 209 .destination 210 .join(String::from(parent)) 211 .join(String::from(fname)); 212 213 write_page(context.clone(), file.clone(), output_path).await?; 214 } else { 215 let output_path = context.destination.join(relative_path.clone()); 216 217 write_page(context.clone(), file.clone(), output_path).await?; 218 } 219 } 220 221 // Generate default index if requested and no custom index specified 222 if self 223 .context 224 .options 225 .contains(StaticSiteOptions::CREATE_INDEX) 226 && self.context.index_file.is_none() 227 { 228 self.generate_default_index().await?; 229 } 230 231 Ok(()) 232 } 233 234 #[cfg(feature = "syntax-css")] 235 async fn generate_css_files(&self) -> Result<(), miette::Report> { 236 use crate::css::{generate_base_css, generate_syntax_css}; 237 238 let css_dir = self.context.destination.join("css"); 239 tokio::fs::create_dir_all(&css_dir) 240 .await 241 .into_diagnostic()?; 242 243 let default_theme = default_resolved_theme(); 244 let theme = self.context.theme.as_deref().unwrap_or(&default_theme); 245 246 // Write base.css 247 let base_css = generate_base_css(theme); 248 tokio::fs::write(css_dir.join("base.css"), base_css) 249 .await 250 .into_diagnostic()?; 251 252 // Write syntax.css 253 let syntax_css = generate_syntax_css(theme).await?; 254 tokio::fs::write(css_dir.join("syntax.css"), syntax_css) 255 .await 256 .into_diagnostic()?; 257 258 Ok(()) 259 } 260 261 #[cfg(not(feature = "syntax-css"))] 262 async fn generate_css_files(&self) -> Result<(), miette::Report> { 263 Err(miette::miette!( 264 "CSS generation requires the 'syntax-css' feature" 265 )) 266 } 267 268 async fn generate_default_index(&self) -> Result<(), miette::Report> { 269 let index_path = self.context.destination.join("index.html"); 270 let mut index_file = crate::utils::create_file(&index_path).await?; 271 272 // Write head 273 write_document_head(&self.context, &mut index_file, CssMode::Linked, &index_path).await?; 274 275 // Write title and list 276 index_file 277 .write_all(b"<h1>Index</h1>\n<ul>\n") 278 .await 279 .into_diagnostic()?; 280 281 // List all files 282 if let Some(contents) = &self.context.dir_contents { 283 for file in contents.iter() { 284 if let Ok(relative) = file.strip_prefix(&self.context.start_at) { 285 let display_name = relative.to_string_lossy(); 286 let link = if self 287 .context 288 .options 289 .contains(StaticSiteOptions::FLATTEN_STRUCTURE) 290 { 291 let (parent, fname) = flatten_dir_to_just_one_parent(&display_name); 292 // Change extension to .html 293 let fname_html = 294 PathBuf::from(fname.as_ref() as &str).with_extension("html"); 295 let fname_html_str = fname_html.to_string_lossy(); 296 if !parent.is_empty() { 297 format!("./{}/{}", parent, fname_html_str) 298 } else { 299 format!("./entry/{}", fname_html_str) 300 } 301 } else { 302 // Change extension to .html 303 let html_path = 304 PathBuf::from(display_name.as_ref() as &str).with_extension("html"); 305 format!("./{}", html_path.to_string_lossy()) 306 }; 307 308 index_file 309 .write_all( 310 format!(" <li><a href=\"{}\">{}</a></li>\n", link, display_name) 311 .as_bytes(), 312 ) 313 .await 314 .into_diagnostic()?; 315 } 316 } 317 } 318 319 index_file.write_all(b"</ul>\n").await.into_diagnostic()?; 320 321 // Write footer 322 write_document_footer(&mut index_file).await?; 323 324 Ok(()) 325 } 326} 327 328pub async fn export_page<'input, A>( 329 contents: &'input str, 330 context: StaticSiteContext<A>, 331) -> Result<String, miette::Report> 332where 333 A: AgentSession + IdentityResolver, 334{ 335 let callback = if let Some(dir_contents) = context.dir_contents.clone() { 336 Some(VaultBrokenLinkCallback { 337 vault_contents: dir_contents, 338 }) 339 } else { 340 None 341 }; 342 let parser = Parser::new_with_broken_link_callback(&contents, context.md_options, callback) 343 .into_offset_iter(); 344 let iterator = ContextIterator::default(parser); 345 let mut output = String::new(); 346 let writer = StaticPageWriter::new( 347 NotebookProcessor::new(context, iterator), 348 FmtWriter(&mut output), 349 contents, 350 ); 351 writer.run().await.into_diagnostic()?; 352 Ok(output) 353} 354 355pub async fn write_page<A>( 356 context: StaticSiteContext<A>, 357 input_path: impl AsRef<Path>, 358 output_path: impl AsRef<Path>, 359) -> Result<(), miette::Report> 360where 361 A: AgentSession + IdentityResolver, 362{ 363 let contents = tokio::fs::read_to_string(&input_path) 364 .await 365 .into_diagnostic()?; 366 367 // Change extension to .html 368 let output_path = output_path.as_ref().with_extension("html"); 369 let mut output_file = crate::utils::create_file(&output_path).await?; 370 let context = context.clone_with_path(input_path); 371 372 // Write document head 373 write_document_head(&context, &mut output_file, CssMode::Linked, &output_path).await?; 374 375 // Write body content 376 let output = export_page(&contents, context).await?; 377 output_file 378 .write_all(output.as_bytes()) 379 .await 380 .into_diagnostic()?; 381 382 // Write document footer 383 write_document_footer(&mut output_file).await?; 384 385 Ok(()) 386} 387 388pub async fn write_page_standalone<A>( 389 context: StaticSiteContext<A>, 390 input_path: impl AsRef<Path>, 391 output_path: impl AsRef<Path>, 392) -> Result<(), miette::Report> 393where 394 A: AgentSession + IdentityResolver, 395{ 396 let contents = tokio::fs::read_to_string(&input_path) 397 .await 398 .into_diagnostic()?; 399 400 // Change extension to .html 401 let output_path = output_path.as_ref().with_extension("html"); 402 let mut output_file = crate::utils::create_file(&output_path).await?; 403 let context = context.clone_with_path(input_path); 404 405 // Write document head with inline CSS 406 write_document_head(&context, &mut output_file, CssMode::Inline, &output_path).await?; 407 408 // Write body content 409 let output = export_page(&contents, context).await?; 410 output_file 411 .write_all(output.as_bytes()) 412 .await 413 .into_diagnostic()?; 414 415 // Write document footer 416 write_document_footer(&mut output_file).await?; 417 418 Ok(()) 419} 420 421#[cfg(test)] 422mod tests;