modularizing, theming, proper html output

Orual e7931103 03b64b11

+1587 -1111
+1
Cargo.lock
··· 5787 "pin-utils", 5788 "regex", 5789 "reqwest", 5790 "syntect", 5791 "thiserror 2.0.17", 5792 "tokio",
··· 5787 "pin-utils", 5788 "regex", 5789 "reqwest", 5790 + "smol_str", 5791 "syntect", 5792 "thiserror 2.0.17", 5793 "tokio",
+1
crates/weaver-renderer/Cargo.toml
··· 28 pin-project = "1.1.10" 29 dynosaur = "0.2.0" 30 async-trait = "0.1.88" 31 reqwest = { version = "0.12.7", default-features = false, features = [ 32 "json", 33 "rustls-tls",
··· 28 pin-project = "1.1.10" 29 dynosaur = "0.2.0" 30 async-trait = "0.1.88" 31 + smol_str = { version = "0.3", features = ["serde"] } 32 reqwest = { version = "0.12.7", default-features = false, features = [ 33 "json", 34 "rustls-tls",
+2 -2
crates/weaver-renderer/src/code_pretty.rs
··· 9 /// This requires an external stylesheet, also generated by syntect to be loaded by the page. 10 /// The syntect SyntaxSet is also provided, so that it is not re-created on every call. 11 pub fn highlight<M>( 12 - syn_set: SyntaxSet, 13 lang: Option<&str>, 14 code: impl AsRef<str>, 15 writer: &mut M, ··· 33 34 let mut html_gen = ClassedHTMLGenerator::new_with_class_style( 35 lang_syn, 36 - &syn_set, 37 ClassStyle::SpacedPrefixed { prefix: CSS_PREFIX }, 38 ); 39 for line in LinesWithEndings::from(code.as_ref()) {
··· 9 /// This requires an external stylesheet, also generated by syntect to be loaded by the page. 10 /// The syntect SyntaxSet is also provided, so that it is not re-created on every call. 11 pub fn highlight<M>( 12 + syn_set: &SyntaxSet, 13 lang: Option<&str>, 14 code: impl AsRef<str>, 15 writer: &mut M, ··· 33 34 let mut html_gen = ClassedHTMLGenerator::new_with_class_style( 35 lang_syn, 36 + syn_set, 37 ClassStyle::SpacedPrefixed { prefix: CSS_PREFIX }, 38 ); 39 for line in LinesWithEndings::from(code.as_ref()) {
+200
crates/weaver-renderer/src/css.rs
···
··· 1 + use crate::theme::Theme; 2 + use miette::IntoDiagnostic; 3 + use syntect::highlighting::ThemeSet; 4 + use syntect::html::{ClassStyle, css_for_theme_with_class_style}; 5 + use syntect::parsing::SyntaxSet; 6 + 7 + pub fn generate_base_css(theme: &Theme) -> String { 8 + format!( 9 + r#"/* CSS Reset */ 10 + *, *::before, *::after {{ 11 + box-sizing: border-box; 12 + margin: 0; 13 + padding: 0; 14 + }} 15 + 16 + /* CSS Variables */ 17 + :root {{ 18 + --color-background: {}; 19 + --color-foreground: {}; 20 + --color-link: {}; 21 + --color-link-hover: {}; 22 + 23 + --font-body: {}; 24 + --font-heading: {}; 25 + --font-mono: {}; 26 + 27 + --spacing-base: {}; 28 + --spacing-line-height: {}; 29 + --spacing-scale: {}; 30 + }} 31 + 32 + /* Base Styles */ 33 + html {{ 34 + font-size: var(--spacing-base); 35 + line-height: var(--spacing-line-height); 36 + }} 37 + 38 + body {{ 39 + font-family: var(--font-body); 40 + color: var(--color-foreground); 41 + background-color: var(--color-background); 42 + max-width: 65ch; 43 + margin: 0 auto; 44 + padding: 2rem 1rem; 45 + }} 46 + 47 + /* Typography */ 48 + h1, h2, h3, h4, h5, h6 {{ 49 + font-family: var(--font-heading); 50 + margin-top: calc(1rem * var(--spacing-scale)); 51 + margin-bottom: 0.5rem; 52 + line-height: 1.2; 53 + }} 54 + 55 + h1 {{ font-size: 2.5rem; }} 56 + h2 {{ font-size: 2rem; }} 57 + h3 {{ font-size: 1.5rem; }} 58 + h4 {{ font-size: 1.25rem; }} 59 + h5 {{ font-size: 1.125rem; }} 60 + h6 {{ font-size: 1rem; }} 61 + 62 + p {{ 63 + margin-bottom: 1rem; 64 + }} 65 + 66 + a {{ 67 + color: var(--color-link); 68 + text-decoration: none; 69 + }} 70 + 71 + a:hover {{ 72 + color: var(--color-link-hover); 73 + text-decoration: underline; 74 + }} 75 + 76 + /* Lists */ 77 + ul, ol {{ 78 + margin-left: 2rem; 79 + margin-bottom: 1rem; 80 + }} 81 + 82 + li {{ 83 + margin-bottom: 0.25rem; 84 + }} 85 + 86 + /* Code */ 87 + code {{ 88 + font-family: var(--font-mono); 89 + background-color: rgba(0, 0, 0, 0.05); 90 + padding: 0.125rem 0.25rem; 91 + border-radius: 3px; 92 + font-size: 0.9em; 93 + }} 94 + 95 + pre {{ 96 + overflow-x: auto; 97 + margin-bottom: 1rem; 98 + }} 99 + 100 + pre code {{ 101 + display: block; 102 + padding: 1rem; 103 + background-color: rgba(0, 0, 0, 0.03); 104 + border-radius: 5px; 105 + }} 106 + 107 + /* Math */ 108 + .math {{ 109 + font-family: var(--font-mono); 110 + }} 111 + 112 + .math-display {{ 113 + display: block; 114 + margin: 1rem 0; 115 + text-align: center; 116 + }} 117 + 118 + /* Blockquotes */ 119 + blockquote {{ 120 + border-left: 4px solid var(--color-link); 121 + padding-left: 1rem; 122 + margin: 1rem 0; 123 + font-style: italic; 124 + }} 125 + 126 + /* Tables */ 127 + table {{ 128 + border-collapse: collapse; 129 + width: 100%; 130 + margin-bottom: 1rem; 131 + }} 132 + 133 + th, td {{ 134 + border: 1px solid rgba(0, 0, 0, 0.1); 135 + padding: 0.5rem; 136 + text-align: left; 137 + }} 138 + 139 + th {{ 140 + background-color: rgba(0, 0, 0, 0.05); 141 + font-weight: 600; 142 + }} 143 + 144 + /* Footnotes */ 145 + .footnote-reference {{ 146 + font-size: 0.8em; 147 + }} 148 + 149 + .footnote-definition {{ 150 + margin-top: 2rem; 151 + padding-top: 0.5rem; 152 + border-top: 1px solid rgba(0, 0, 0, 0.1); 153 + font-size: 0.9em; 154 + }} 155 + 156 + .footnote-definition-label {{ 157 + font-weight: 600; 158 + margin-right: 0.5rem; 159 + }} 160 + 161 + /* Horizontal Rule */ 162 + hr {{ 163 + border: none; 164 + border-top: 1px solid rgba(0, 0, 0, 0.1); 165 + margin: 2rem 0; 166 + }} 167 + "#, 168 + theme.colors.background, 169 + theme.colors.foreground, 170 + theme.colors.link, 171 + theme.colors.link_hover, 172 + theme.fonts.body, 173 + theme.fonts.heading, 174 + theme.fonts.monospace, 175 + theme.spacing.base_font_size, 176 + theme.spacing.line_height, 177 + theme.spacing.scale, 178 + ) 179 + } 180 + 181 + pub fn generate_syntax_css( 182 + syntect_theme_name: &str, 183 + _syntax_set: &SyntaxSet, 184 + ) -> miette::Result<String> { 185 + let theme_set = ThemeSet::load_defaults(); 186 + let theme = theme_set 187 + .themes 188 + .get(syntect_theme_name) 189 + .ok_or_else(|| miette::miette!("Theme '{}' not found", syntect_theme_name))?; 190 + 191 + let css = css_for_theme_with_class_style( 192 + theme, 193 + ClassStyle::SpacedPrefixed { 194 + prefix: crate::code_pretty::CSS_PREFIX, 195 + }, 196 + ) 197 + .into_diagnostic()?; 198 + 199 + Ok(css) 200 + }
+2
crates/weaver-renderer/src/lib.rs
··· 27 pub mod atproto; 28 pub mod base_html; 29 pub mod code_pretty; 30 pub mod static_site; 31 pub mod types; 32 pub mod utils; 33 pub mod walker;
··· 27 pub mod atproto; 28 pub mod base_html; 29 pub mod code_pretty; 30 + pub mod css; 31 pub mod static_site; 32 + pub mod theme; 33 pub mod types; 34 pub mod utils; 35 pub mod walker;
+56 -1109
crates/weaver-renderer/src/static_site.rs
··· 5 //! URLs in the notebook are mostly unaltered. It is compatible with GitHub or Cloudflare Pages 6 //! and other similar static hosting services. 7 8 - use std::{ 9 - path::{Path, PathBuf}, 10 - sync::Arc, 11 - }; 12 13 use crate::{ 14 ContextIterator, NotebookProcessor, 15 - base_html::TableState, 16 utils::flatten_dir_to_just_one_parent, 17 walker::{WalkOptions, vault_contents}, 18 }; 19 use bitflags::bitflags; 20 - use dashmap::DashMap; 21 - use markdown_weaver::{ 22 - Alignment, BlockQuoteKind, BrokenLink, CodeBlockKind, CowStr, EmbedType, Event, LinkType, 23 - Parser, Tag, WeaverAttributes, 24 - }; 25 - use markdown_weaver_escape::{ 26 - FmtWriter, StrWrite, escape_href, escape_html, escape_html_body_text, 27 - }; 28 use miette::IntoDiagnostic; 29 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 30 use n0_future::io::AsyncWriteExt; 31 - use n0_future::{IterExt, StreamExt}; 32 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 33 use tokio::io::AsyncWriteExt; 34 use unicode_normalization::UnicodeNormalization; 35 - use weaver_common::{ 36 - aturi_to_http, 37 - jacquard::{ 38 - client::{Agent, AgentSession, AgentSessionExt}, 39 - identity::resolver::IdentityError, 40 - prelude::*, 41 - types::blob::MimeType, 42 - }, 43 - }; 44 - use yaml_rust2::Yaml; 45 - 46 - use crate::{Frontmatter, NotebookContext}; 47 48 bitflags! { 49 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] ··· 88 | markdown_weaver::Options::ENABLE_HEADING_ATTRIBUTES 89 } 90 91 - pub struct StaticSiteContext<'a, A: AgentSession> { 92 - options: StaticSiteOptions, 93 - md_options: markdown_weaver::Options, 94 - pub bsky_appview: CowStr<'a>, 95 - root: PathBuf, 96 - pub destination: PathBuf, 97 - start_at: PathBuf, 98 - pub frontmatter: Arc<DashMap<PathBuf, Frontmatter>>, 99 - dir_contents: Option<Arc<[PathBuf]>>, 100 - reference_map: Arc<DashMap<CowStr<'a>, PathBuf>>, 101 - titles: Arc<DashMap<PathBuf, CowStr<'a>>>, 102 - position: usize, 103 - client: Option<reqwest::Client>, 104 - agent: Option<Arc<Agent<A>>>, 105 - } 106 - 107 - impl<'a, A: AgentSession> Clone for StaticSiteContext<'a, A> { 108 - fn clone(&self) -> Self { 109 - Self { 110 - options: self.options.clone(), 111 - md_options: self.md_options.clone(), 112 - bsky_appview: self.bsky_appview.clone(), 113 - root: self.root.clone(), 114 - destination: self.destination.clone(), 115 - start_at: self.start_at.clone(), 116 - frontmatter: self.frontmatter.clone(), 117 - dir_contents: self.dir_contents.clone(), 118 - reference_map: self.reference_map.clone(), 119 - titles: self.titles.clone(), 120 - position: self.position.clone(), 121 - client: self.client.clone(), 122 - agent: self.agent.clone(), 123 - } 124 - } 125 - } 126 - 127 - impl<A: AgentSession> StaticSiteContext<'_, A> { 128 - pub fn clone_with_dir_contents(&self, dir_contents: &[PathBuf]) -> Self { 129 - Self { 130 - start_at: self.start_at.clone(), 131 - root: self.root.clone(), 132 - bsky_appview: self.bsky_appview.clone(), 133 - options: self.options.clone(), 134 - md_options: self.md_options.clone(), 135 - frontmatter: self.frontmatter.clone(), 136 - dir_contents: Some(Arc::from(dir_contents)), 137 - destination: self.destination.clone(), 138 - reference_map: self.reference_map.clone(), 139 - titles: self.titles.clone(), 140 - position: self.position, 141 - client: self.client.clone(), 142 - agent: self.agent.clone(), 143 - } 144 - } 145 - 146 - pub fn clone_with_path(&self, path: impl AsRef<Path>) -> Self { 147 - let position = if let Some(dir_contents) = &self.dir_contents { 148 - dir_contents 149 - .iter() 150 - .position(|p| p == path.as_ref()) 151 - .unwrap_or(0) 152 - } else { 153 - 0 154 - }; 155 - Self { 156 - start_at: self.start_at.clone(), 157 - root: self.root.clone(), 158 - bsky_appview: self.bsky_appview.clone(), 159 - options: self.options.clone(), 160 - md_options: self.md_options.clone(), 161 - frontmatter: self.frontmatter.clone(), 162 - dir_contents: self.dir_contents.clone(), 163 - destination: self.destination.clone(), 164 - reference_map: self.reference_map.clone(), 165 - titles: self.titles.clone(), 166 - position, 167 - client: Some(reqwest::Client::default()), 168 - agent: self.agent.clone(), 169 - } 170 - } 171 - pub fn new(root: PathBuf, destination: PathBuf, session: Option<A>) -> Self { 172 - Self { 173 - start_at: root.clone(), 174 - root, 175 - bsky_appview: CowStr::Borrowed("deer.social"), 176 - options: StaticSiteOptions::default(), 177 - md_options: default_md_options(), 178 - frontmatter: Arc::new(DashMap::new()), 179 - dir_contents: None, 180 - destination, 181 - reference_map: Arc::new(DashMap::new()), 182 - titles: Arc::new(DashMap::new()), 183 - position: 0, 184 - client: Some(reqwest::Client::default()), 185 - agent: session.map(|session| Arc::new(Agent::new(session))), 186 - } 187 - } 188 - 189 - pub fn current_path(&self) -> &PathBuf { 190 - if let Some(dir_contents) = &self.dir_contents { 191 - &dir_contents[self.position] 192 - } else { 193 - &self.start_at 194 - } 195 - } 196 - 197 - #[inline] 198 - pub fn handle_link_aturi<'s>(&self, link: Tag<'s>) -> Tag<'s> { 199 - let link = crate::utils::resolve_at_ident_or_uri(&link, &self.bsky_appview); 200 - self.handle_link_normal(link) 201 - } 202 - 203 - pub async fn handle_embed_aturi<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 204 - match &embed { 205 - Tag::Embed { 206 - embed_type, 207 - dest_url, 208 - title, 209 - id, 210 - attrs, 211 - } => { 212 - if dest_url.starts_with("at://") { 213 - let width = if let Some(attrs) = attrs { 214 - let mut width = 600; 215 - for attr in &attrs.attrs { 216 - if attr.0 == CowStr::Borrowed("width".into()) { 217 - width = attr.1.parse::<usize>().unwrap_or(600); 218 - break; 219 - } 220 - } 221 - width 222 - } else { 223 - 600 224 - }; 225 - let html = if let Some(client) = &self.client { 226 - if let Ok(resp) = client 227 - .get("https://embed.bsky.app/oembed") 228 - .query(&[ 229 - ("url", dest_url.clone().into_string()), 230 - ("maxwidth", width.to_string()), 231 - ]) 232 - .send() 233 - .await 234 - { 235 - resp.text().await.ok() 236 - } else { 237 - None 238 - } 239 - } else { 240 - None 241 - }; 242 - if let Some(html) = html { 243 - let link = aturi_to_http(&dest_url, &self.bsky_appview) 244 - .expect("assuming the at-uri is valid rn"); 245 - let mut attrs = if let Some(attrs) = attrs { 246 - attrs.clone() 247 - } else { 248 - WeaverAttributes { 249 - classes: vec![], 250 - attrs: vec![], 251 - } 252 - }; 253 - attrs.attrs.push(("content".into(), html.into())); 254 - Tag::Embed { 255 - embed_type: EmbedType::Comments, // change this when i update markdown-weaver 256 - dest_url: link.into_static(), 257 - title: title.clone(), 258 - id: id.clone(), 259 - attrs: Some(attrs), 260 - } 261 - } else { 262 - self.handle_embed_normal(embed).await 263 - } 264 - } else { 265 - self.handle_embed_normal(embed).await 266 - } 267 - } 268 - _ => embed, 269 - } 270 - } 271 - 272 - pub async fn handle_embed_normal<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 273 - // This option will REALLY slow down iteration over events. 274 - if self.options.contains(StaticSiteOptions::INLINE_EMBEDS) { 275 - match &embed { 276 - Tag::Embed { 277 - embed_type: _, 278 - dest_url, 279 - title, 280 - id, 281 - attrs, 282 - } => { 283 - let mut attrs = if let Some(attrs) = attrs { 284 - attrs.clone() 285 - } else { 286 - WeaverAttributes { 287 - classes: vec![], 288 - attrs: vec![], 289 - } 290 - }; 291 - let contents = if crate::utils::is_local_path(dest_url) { 292 - let file_path = if crate::utils::is_relative_link(dest_url) { 293 - let root_path = self.root.clone(); 294 - root_path.join(Path::new(&dest_url as &str)) 295 - } else { 296 - PathBuf::from(&dest_url as &str) 297 - }; 298 - crate::utils::inline_file(&file_path).await 299 - } else if let Some(client) = &self.client { 300 - if let Ok(resp) = client.get(dest_url.clone().into_string()).send().await { 301 - resp.text().await.ok() 302 - } else { 303 - None 304 - } 305 - } else { 306 - None 307 - }; 308 - if let Some(contents) = contents { 309 - attrs.attrs.push(("content".into(), contents.into())); 310 - Tag::Embed { 311 - embed_type: EmbedType::Markdown, // change this when i update markdown-weaver 312 - dest_url: dest_url.clone(), 313 - title: title.clone(), 314 - id: id.clone(), 315 - attrs: Some(attrs), 316 - } 317 - } else { 318 - embed 319 - } 320 - } 321 - _ => embed, 322 - } 323 - } else { 324 - embed 325 - } 326 - } 327 - 328 - /// This is a no-op for the static site renderer currently. 329 - #[inline] 330 - pub fn handle_link_normal<'s>(&self, link: Tag<'s>) -> Tag<'s> { 331 - link 332 - } 333 - 334 - /// This is a no-op for the static site renderer currently. 335 - #[inline] 336 - pub fn handle_image_normal<'s>(&self, image: Tag<'s>) -> Tag<'s> { 337 - image 338 - } 339 - } 340 - 341 - impl<A: AgentSession + IdentityResolver> StaticSiteContext<'_, A> { 342 - /// TODO: rework this a bit, to not just do the same thing as whitewind 343 - /// (also need to make a record to refer to them) that being said, doing 344 - /// this with the static site renderer isn't *really* the standard workflow 345 - pub async fn upload_image<'s>(&self, image: Tag<'s>) -> Tag<'s> { 346 - if let Some(agent) = &self.agent { 347 - match &image { 348 - Tag::Image { 349 - link_type, 350 - dest_url, 351 - title, 352 - id, 353 - attrs, 354 - } => { 355 - if crate::utils::is_local_path(&dest_url) { 356 - let root_path = self.root.clone(); 357 - let file_path = root_path.join(Path::new(&dest_url as &str)); 358 - if let Ok(image_data) = std::fs::read(&file_path) { 359 - if let Ok(blob) = agent 360 - .upload_blob(image_data, MimeType::new_static("image/jpg")) 361 - .await 362 - { 363 - let (did, _) = agent.info().await.unwrap(); 364 - let url = weaver_common::blob_url( 365 - &did, 366 - agent.endpoint().await.as_str(), 367 - &blob.r#ref.0, 368 - ); 369 - return Tag::Image { 370 - link_type: *link_type, 371 - dest_url: url.into(), 372 - title: title.clone(), 373 - id: id.clone(), 374 - attrs: attrs.clone(), 375 - }; 376 - } 377 - } 378 - } 379 - } 380 - _ => {} 381 - } 382 - } 383 - image 384 - } 385 - } 386 - 387 - impl<A: AgentSession + IdentityResolver> NotebookContext for StaticSiteContext<'_, A> { 388 - fn set_entry_title(&self, title: CowStr<'_>) { 389 - let path = self.current_path(); 390 - self.titles 391 - .insert(path.clone(), title.clone().into_static()); 392 - self.frontmatter.get_mut(path).map(|frontmatter| { 393 - if let Ok(mut yaml) = frontmatter.yaml.write() { 394 - if yaml.get(0).is_some_and(|y| y.is_hash()) { 395 - let map = yaml.get_mut(0).unwrap().as_mut_hash().unwrap(); 396 - map.insert( 397 - Yaml::String("title".into()), 398 - Yaml::String(title.into_static().into()), 399 - ); 400 - } 401 - } 402 - }); 403 - } 404 - fn entry_title(&self) -> CowStr<'_> { 405 - let path = self.current_path(); 406 - self.titles.get(path).unwrap().clone() 407 - } 408 - 409 - fn frontmatter(&self) -> Frontmatter { 410 - let path = self.current_path(); 411 - self.frontmatter.get(path).unwrap().value().clone() 412 - } 413 - 414 - fn set_frontmatter(&self, frontmatter: Frontmatter) { 415 - let path = self.current_path(); 416 - self.frontmatter.insert(path.clone(), frontmatter); 417 - } 418 - 419 - async fn handle_link<'s>(&self, link: Tag<'s>) -> Tag<'s> { 420 - bitflags::bitflags_match!(self.options, { 421 - // Split this somehow or just combine the options 422 - StaticSiteOptions::RESOLVE_AT_URIS | StaticSiteOptions::RESOLVE_AT_IDENTIFIERS => { 423 - self.handle_link_aturi(link) 424 - } 425 - _ => match &link { 426 - Tag::Link { link_type, dest_url, title, id } => { 427 - if self.options.contains(StaticSiteOptions::FLATTEN_STRUCTURE) { 428 - let (parent, filename) = crate::utils::flatten_dir_to_just_one_parent(&dest_url); 429 - let dest_url = if crate::utils::is_relative_link(&dest_url) 430 - && self.options.contains(StaticSiteOptions::CREATE_CHAPTERS_BY_DIRECTORY) { 431 - if !parent.is_empty() { 432 - CowStr::Boxed(format!("./{}/{}", parent, filename).into_boxed_str()) 433 - } else { 434 - CowStr::Boxed(format!("./{}", filename).into_boxed_str()) 435 - } 436 - } else { 437 - CowStr::Boxed(format!("./entry/{}", filename).into_boxed_str()) 438 - }; 439 - Tag::Link { 440 - link_type: *link_type, 441 - dest_url, 442 - title: title.clone(), 443 - id: id.clone(), 444 - } 445 - } else { 446 - link 447 - 448 - } 449 - }, 450 - _ => link, 451 - } 452 - }) 453 - } 454 - 455 - async fn handle_image<'s>(&self, image: Tag<'s>) -> Tag<'s> { 456 - if self.options.contains(StaticSiteOptions::UPLOAD_BLOBS) { 457 - self.upload_image(image).await 458 - } else { 459 - self.handle_image_normal(image) 460 - } 461 - } 462 - 463 - async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 464 - if self.options.contains(StaticSiteOptions::RESOLVE_AT_URIS) 465 - || self.options.contains(StaticSiteOptions::ADD_LINK_PREVIEWS) 466 - { 467 - self.handle_embed_aturi(embed).await 468 - } else { 469 - self.handle_embed_normal(embed).await 470 - } 471 - } 472 - 473 - fn handle_reference(&self, reference: CowStr<'_>) -> CowStr<'_> { 474 - let reference = reference.into_static(); 475 - if let Some(reference) = self.reference_map.get(&reference) { 476 - let path = reference.value().clone(); 477 - CowStr::Boxed(path.to_string_lossy().into_owned().into_boxed_str()) 478 - } else { 479 - reference 480 - } 481 - } 482 - 483 - fn add_reference(&self, reference: CowStr<'_>) { 484 - let path = self.current_path(); 485 - self.reference_map 486 - .insert(reference.into_static(), path.clone()); 487 - } 488 - } 489 - 490 - pub struct StaticSiteWriter<'a, A> 491 where 492 A: AgentSession, 493 { 494 - context: StaticSiteContext<'a, A>, 495 } 496 497 - impl<'a, A> StaticSiteWriter<'a, A> 498 where 499 A: AgentSession, 500 { ··· 504 } 505 } 506 507 - impl<'a, A> StaticSiteWriter<'a, A> 508 where 509 - A: AgentSession + IdentityResolver + 'a, 510 { 511 pub async fn run(mut self) -> Result<(), miette::Report> { 512 if !self.context.root.exists() { ··· 555 )); 556 } 557 558 for file in self 559 .context 560 .dir_contents ··· 565 .filter(|file| file.starts_with(&self.context.start_at)) 566 { 567 let context = self.context.clone(); 568 - let relative_path = file 569 - .strip_prefix(context.start_at.clone()) 570 - .expect("file should always be nested under root") 571 - .to_path_buf(); 572 - if context 573 - .options 574 - .contains(StaticSiteOptions::FLATTEN_STRUCTURE) 575 - { 576 - let path_str = relative_path.to_string_lossy(); 577 - let (parent, file) = flatten_dir_to_just_one_parent(&path_str); 578 - let output_path = context 579 - .destination 580 - .join(String::from(parent)) 581 - .join(String::from(file)); 582 583 - write_page(context.clone(), relative_path, output_path).await?; 584 - } else { 585 - let output_path = context.destination.join(relative_path.clone()); 586 587 - write_page(context.clone(), relative_path, output_path).await?; 588 - } 589 } 590 Ok(()) 591 } 592 } 593 594 - pub async fn export_page<'s, 'input, A>( 595 contents: &'input str, 596 - context: StaticSiteContext<'s, A>, 597 ) -> Result<String, miette::Report> 598 where 599 A: AgentSession + IdentityResolver, ··· 616 Ok(output) 617 } 618 619 - pub async fn write_page<'s, A>( 620 - context: StaticSiteContext<'s, A>, 621 input_path: impl AsRef<Path>, 622 output_path: impl AsRef<Path>, 623 ) -> Result<(), miette::Report> ··· 637 Ok(()) 638 } 639 640 - pub struct StaticPageWriter< 641 - 'a, 642 - 'input, 643 - I: Iterator<Item = Event<'input>>, 644 - A: AgentSession, 645 - W: StrWrite, 646 - > { 647 - context: NotebookProcessor<'input, I, StaticSiteContext<'a, A>>, 648 - writer: W, 649 - /// Whether or not the last write wrote a newline. 650 - end_newline: bool, 651 - 652 - /// Whether if inside a metadata block (text should not be written) 653 - in_non_writing_block: bool, 654 - 655 - table_state: TableState, 656 - table_alignments: Vec<Alignment>, 657 - table_cell_index: usize, 658 - numbers: DashMap<CowStr<'a>, usize>, 659 - } 660 - 661 - impl<'a, 'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> 662 - StaticPageWriter<'a, 'input, I, A, W> 663 - { 664 - pub fn new(context: NotebookProcessor<'input, I, StaticSiteContext<'a, A>>, writer: W) -> Self { 665 - Self { 666 - context, 667 - writer, 668 - end_newline: true, 669 - in_non_writing_block: false, 670 - table_state: TableState::Head, 671 - table_alignments: vec![], 672 - table_cell_index: 0, 673 - numbers: DashMap::new(), 674 - } 675 - } 676 - 677 - /// Writes a new line. 678 - #[inline] 679 - fn write_newline(&mut self) -> Result<(), W::Error> { 680 - self.end_newline = true; 681 - self.writer.write_str("\n") 682 - } 683 - 684 - /// Writes a buffer, and tracks whether or not a newline was written. 685 - #[inline] 686 - fn write(&mut self, s: &str) -> Result<(), W::Error> { 687 - self.writer.write_str(s)?; 688 - 689 - if !s.is_empty() { 690 - self.end_newline = s.ends_with('\n'); 691 - } 692 - Ok(()) 693 - } 694 - 695 - fn end_tag(&mut self, tag: markdown_weaver::TagEnd) -> Result<(), W::Error> { 696 - use markdown_weaver::TagEnd; 697 - match tag { 698 - TagEnd::HtmlBlock => {} 699 - TagEnd::Paragraph => { 700 - self.write("</p>\n")?; 701 - } 702 - TagEnd::Heading(level) => { 703 - self.write("</")?; 704 - write!(&mut self.writer, "{}", level)?; 705 - self.write(">\n")?; 706 - } 707 - TagEnd::Table => { 708 - self.write("</tbody></table>\n")?; 709 - } 710 - TagEnd::TableHead => { 711 - self.write("</tr></thead><tbody>\n")?; 712 - self.table_state = TableState::Body; 713 - } 714 - TagEnd::TableRow => { 715 - self.write("</tr>\n")?; 716 - } 717 - TagEnd::TableCell => { 718 - match self.table_state { 719 - TableState::Head => { 720 - self.write("</th>")?; 721 - } 722 - TableState::Body => { 723 - self.write("</td>")?; 724 - } 725 - } 726 - self.table_cell_index += 1; 727 - } 728 - TagEnd::BlockQuote(_) => { 729 - self.write("</blockquote>\n")?; 730 - } 731 - TagEnd::CodeBlock => { 732 - self.write("</code></pre>\n")?; 733 - } 734 - TagEnd::List(true) => { 735 - self.write("</ol>\n")?; 736 - } 737 - TagEnd::List(false) => { 738 - self.write("</ul>\n")?; 739 - } 740 - TagEnd::Item => { 741 - self.write("</li>\n")?; 742 - } 743 - TagEnd::DefinitionList => { 744 - self.write("</dl>\n")?; 745 - } 746 - TagEnd::DefinitionListTitle => { 747 - self.write("</dt>\n")?; 748 - } 749 - TagEnd::DefinitionListDefinition => { 750 - self.write("</dd>\n")?; 751 - } 752 - TagEnd::Emphasis => { 753 - self.write("</em>")?; 754 - } 755 - TagEnd::Superscript => { 756 - self.write("</sup>")?; 757 - } 758 - TagEnd::Subscript => { 759 - self.write("</sub>")?; 760 - } 761 - TagEnd::Strong => { 762 - self.write("</strong>")?; 763 - } 764 - TagEnd::Strikethrough => { 765 - self.write("</del>")?; 766 - } 767 - TagEnd::Link => { 768 - self.write("</a>")?; 769 - } 770 - TagEnd::Image => (), // shouldn't happen, handled in start 771 - TagEnd::Embed => (), // shouldn't happen, handled in start 772 - TagEnd::WeaverBlock(_) => { 773 - self.in_non_writing_block = false; 774 - } 775 - TagEnd::FootnoteDefinition => { 776 - self.write("</div>\n")?; 777 - } 778 - TagEnd::MetadataBlock(_) => { 779 - self.in_non_writing_block = false; 780 - } 781 - } 782 - Ok(()) 783 - } 784 - } 785 - 786 - impl<'a, 'input, I: Iterator<Item = Event<'input>>, A: AgentSession + IdentityResolver, W: StrWrite> 787 - StaticPageWriter<'a, 'input, I, A, W> 788 - { 789 - async fn run(mut self) -> Result<(), W::Error> { 790 - while let Some(event) = self.context.next().await { 791 - self.process_event(event).await? 792 - } 793 - Ok(()) 794 - } 795 - 796 - async fn process_event(&mut self, event: Event<'input>) -> Result<(), W::Error> { 797 - use markdown_weaver::Event::*; 798 - match event { 799 - Start(tag) => { 800 - self.start_tag(tag).await?; 801 - } 802 - End(tag) => { 803 - self.end_tag(tag)?; 804 - } 805 - Text(text) => { 806 - if !self.in_non_writing_block { 807 - escape_html_body_text(&mut self.writer, &text)?; 808 - self.end_newline = text.ends_with('\n'); 809 - } 810 - } 811 - Code(text) => { 812 - self.write("<code>")?; 813 - escape_html_body_text(&mut self.writer, &text)?; 814 - self.write("</code>")?; 815 - } 816 - InlineMath(text) => { 817 - self.write(r#"<span class="math math-inline">"#)?; 818 - escape_html(&mut self.writer, &text)?; 819 - self.write("</span>")?; 820 - } 821 - DisplayMath(text) => { 822 - self.write(r#"<span class="math math-display">"#)?; 823 - escape_html(&mut self.writer, &text)?; 824 - self.write("</span>")?; 825 - } 826 - Html(html) | InlineHtml(html) => { 827 - self.write(&html)?; 828 - } 829 - SoftBreak => { 830 - self.write_newline()?; 831 - } 832 - HardBreak => { 833 - self.write("<br />\n")?; 834 - } 835 - Rule => { 836 - if self.end_newline { 837 - self.write("<hr />\n")?; 838 - } else { 839 - self.write("\n<hr />\n")?; 840 - } 841 - } 842 - FootnoteReference(name) => { 843 - let len = self.numbers.len() + 1; 844 - self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 845 - escape_html(&mut self.writer, &name)?; 846 - self.write("\">")?; 847 - let number = *self.numbers.entry(name.into_static()).or_insert(len); 848 - write!(&mut self.writer, "{}", number)?; 849 - self.write("</a></sup>")?; 850 - } 851 - TaskListMarker(true) => { 852 - self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?; 853 - } 854 - TaskListMarker(false) => { 855 - self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 856 - } 857 - WeaverBlock(_text) => {} 858 - } 859 - Ok(()) 860 - } 861 - 862 - // run raw text, consuming end tag 863 - async fn raw_text(&mut self) -> Result<(), W::Error> { 864 - use markdown_weaver::Event::*; 865 - let mut nest = 0; 866 - while let Some(event) = self.context.next().await { 867 - match event { 868 - Start(_) => nest += 1, 869 - End(_) => { 870 - if nest == 0 { 871 - break; 872 - } 873 - nest -= 1; 874 - } 875 - Html(_) => {} 876 - InlineHtml(text) | Code(text) | Text(text) => { 877 - // Don't use escape_html_body_text here. 878 - // The output of this function is used in the `alt` attribute. 879 - escape_html(&mut self.writer, &text)?; 880 - self.end_newline = text.ends_with('\n'); 881 - } 882 - InlineMath(text) => { 883 - self.write("$")?; 884 - escape_html(&mut self.writer, &text)?; 885 - self.write("$")?; 886 - } 887 - DisplayMath(text) => { 888 - self.write("$$")?; 889 - escape_html(&mut self.writer, &text)?; 890 - self.write("$$")?; 891 - } 892 - SoftBreak | HardBreak | Rule => { 893 - self.write(" ")?; 894 - } 895 - FootnoteReference(name) => { 896 - let len = self.numbers.len() + 1; 897 - let number = *self.numbers.entry(name.into_static()).or_insert(len); 898 - write!(&mut self.writer, "[{}]", number)?; 899 - } 900 - TaskListMarker(true) => self.write("[x]")?, 901 - TaskListMarker(false) => self.write("[ ]")?, 902 - WeaverBlock(_) => { 903 - println!("Weaver block internal"); 904 - } 905 - } 906 - } 907 - Ok(()) 908 - } 909 - 910 - /// Writes the start of an HTML tag. 911 - async fn start_tag(&mut self, tag: Tag<'input>) -> Result<(), W::Error> { 912 - match tag { 913 - Tag::HtmlBlock => Ok(()), 914 - Tag::Paragraph => { 915 - if self.end_newline { 916 - self.write("<p>") 917 - } else { 918 - self.write("\n<p>") 919 - } 920 - } 921 - Tag::Heading { 922 - level, 923 - id, 924 - classes, 925 - attrs, 926 - } => { 927 - if self.end_newline { 928 - self.write("<")?; 929 - } else { 930 - self.write("\n<")?; 931 - } 932 - write!(&mut self.writer, "{}", level)?; 933 - if let Some(id) = id { 934 - self.write(" id=\"")?; 935 - escape_html(&mut self.writer, &id)?; 936 - self.write("\"")?; 937 - } 938 - let mut classes = classes.iter(); 939 - if let Some(class) = classes.next() { 940 - self.write(" class=\"")?; 941 - escape_html(&mut self.writer, class)?; 942 - for class in classes { 943 - self.write(" ")?; 944 - escape_html(&mut self.writer, class)?; 945 - } 946 - self.write("\"")?; 947 - } 948 - for (attr, value) in attrs { 949 - self.write(" ")?; 950 - escape_html(&mut self.writer, &attr)?; 951 - if let Some(val) = value { 952 - self.write("=\"")?; 953 - escape_html(&mut self.writer, &val)?; 954 - self.write("\"")?; 955 - } else { 956 - self.write("=\"\"")?; 957 - } 958 - } 959 - self.write(">") 960 - } 961 - Tag::Table(alignments) => { 962 - self.table_alignments = alignments; 963 - self.write("<table>") 964 - } 965 - Tag::TableHead => { 966 - self.table_state = TableState::Head; 967 - self.table_cell_index = 0; 968 - self.write("<thead><tr>") 969 - } 970 - Tag::TableRow => { 971 - self.table_cell_index = 0; 972 - self.write("<tr>") 973 - } 974 - Tag::TableCell => { 975 - match self.table_state { 976 - TableState::Head => { 977 - self.write("<th")?; 978 - } 979 - TableState::Body => { 980 - self.write("<td")?; 981 - } 982 - } 983 - match self.table_alignments.get(self.table_cell_index) { 984 - Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"), 985 - Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), 986 - Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), 987 - _ => self.write(">"), 988 - } 989 - } 990 - Tag::BlockQuote(kind) => { 991 - let class_str = match kind { 992 - None => "", 993 - Some(kind) => match kind { 994 - BlockQuoteKind::Note => " class=\"markdown-alert-note\"", 995 - BlockQuoteKind::Tip => " class=\"markdown-alert-tip\"", 996 - BlockQuoteKind::Important => " class=\"markdown-alert-important\"", 997 - BlockQuoteKind::Warning => " class=\"markdown-alert-warning\"", 998 - BlockQuoteKind::Caution => " class=\"markdown-alert-caution\"", 999 - }, 1000 - }; 1001 - if self.end_newline { 1002 - self.write(&format!("<blockquote{}>\n", class_str)) 1003 - } else { 1004 - self.write(&format!("\n<blockquote{}>\n", class_str)) 1005 - } 1006 - } 1007 - Tag::CodeBlock(info) => { 1008 - if !self.end_newline { 1009 - self.write_newline()?; 1010 - } 1011 - match info { 1012 - CodeBlockKind::Fenced(info) => { 1013 - let lang = info.split(' ').next().unwrap(); 1014 - if lang.is_empty() { 1015 - self.write("<pre><code>") 1016 - } else { 1017 - self.write("<pre><code class=\"language-")?; 1018 - escape_html(&mut self.writer, lang)?; 1019 - self.write("\">") 1020 - } 1021 - } 1022 - CodeBlockKind::Indented => self.write("<pre><code>"), 1023 - } 1024 - } 1025 - Tag::List(Some(1)) => { 1026 - if self.end_newline { 1027 - self.write("<ol>\n") 1028 - } else { 1029 - self.write("\n<ol>\n") 1030 - } 1031 - } 1032 - Tag::List(Some(start)) => { 1033 - if self.end_newline { 1034 - self.write("<ol start=\"")?; 1035 - } else { 1036 - self.write("\n<ol start=\"")?; 1037 - } 1038 - write!(&mut self.writer, "{}", start)?; 1039 - self.write("\">\n") 1040 - } 1041 - Tag::List(None) => { 1042 - if self.end_newline { 1043 - self.write("<ul>\n") 1044 - } else { 1045 - self.write("\n<ul>\n") 1046 - } 1047 - } 1048 - Tag::Item => { 1049 - if self.end_newline { 1050 - self.write("<li>") 1051 - } else { 1052 - self.write("\n<li>") 1053 - } 1054 - } 1055 - Tag::DefinitionList => { 1056 - if self.end_newline { 1057 - self.write("<dl>\n") 1058 - } else { 1059 - self.write("\n<dl>\n") 1060 - } 1061 - } 1062 - Tag::DefinitionListTitle => { 1063 - if self.end_newline { 1064 - self.write("<dt>") 1065 - } else { 1066 - self.write("\n<dt>") 1067 - } 1068 - } 1069 - Tag::DefinitionListDefinition => { 1070 - if self.end_newline { 1071 - self.write("<dd>") 1072 - } else { 1073 - self.write("\n<dd>") 1074 - } 1075 - } 1076 - Tag::Subscript => self.write("<sub>"), 1077 - Tag::Superscript => self.write("<sup>"), 1078 - Tag::Emphasis => self.write("<em>"), 1079 - Tag::Strong => self.write("<strong>"), 1080 - Tag::Strikethrough => self.write("<del>"), 1081 - Tag::Link { 1082 - link_type: LinkType::Email, 1083 - dest_url, 1084 - title, 1085 - id: _, 1086 - } => { 1087 - self.write("<a href=\"mailto:")?; 1088 - escape_href(&mut self.writer, &dest_url)?; 1089 - if !title.is_empty() { 1090 - self.write("\" title=\"")?; 1091 - escape_html(&mut self.writer, &title)?; 1092 - } 1093 - self.write("\">") 1094 - } 1095 - Tag::Link { 1096 - link_type: _, 1097 - dest_url, 1098 - title, 1099 - id: _, 1100 - } => { 1101 - self.write("<a href=\"")?; 1102 - escape_href(&mut self.writer, &dest_url)?; 1103 - if !title.is_empty() { 1104 - self.write("\" title=\"")?; 1105 - escape_html(&mut self.writer, &title)?; 1106 - } 1107 - self.write("\">") 1108 - } 1109 - Tag::Image { 1110 - link_type, 1111 - dest_url, 1112 - title, 1113 - id, 1114 - attrs, 1115 - } => { 1116 - self.write_image(Tag::Image { 1117 - link_type, 1118 - dest_url, 1119 - title, 1120 - id, 1121 - attrs, 1122 - }) 1123 - .await 1124 - } 1125 - Tag::Embed { 1126 - embed_type, 1127 - dest_url, 1128 - title, 1129 - id, 1130 - attrs, 1131 - } => { 1132 - if let Some(attrs) = attrs { 1133 - if let Some((_, content)) = attrs 1134 - .attrs 1135 - .iter() 1136 - .find(|(attr, _)| attr.as_ref() == "content") 1137 - { 1138 - match embed_type { 1139 - EmbedType::Image => { 1140 - self.write_image(Tag::Image { 1141 - link_type: LinkType::Inline, 1142 - dest_url, 1143 - title, 1144 - id, 1145 - attrs: Some(attrs.clone()), 1146 - }) 1147 - .await? 1148 - } 1149 - EmbedType::Comments => { 1150 - self.write("leaflet would go here\n")?; 1151 - } 1152 - EmbedType::Post => { 1153 - // Bluesky post embed, basically just render the raw html we got 1154 - self.write(content)?; 1155 - self.write_newline()?; 1156 - } 1157 - EmbedType::Markdown => { 1158 - // let context = self 1159 - // .context 1160 - // .context 1161 - // .clone_with_path(&Path::new(&dest_url.to_string())); 1162 - // let callback = 1163 - // if let Some(dir_contents) = context.dir_contents.clone() { 1164 - // Some(VaultBrokenLinkCallback { 1165 - // vault_contents: dir_contents, 1166 - // }) 1167 - // } else { 1168 - // None 1169 - // }; 1170 - // let parser = Parser::new_with_broken_link_callback( 1171 - // &content, 1172 - // context.md_options, 1173 - // callback, 1174 - // ); 1175 - // let iterator = ContextIterator::default(parser); 1176 - // let mut stream = NotebookProcessor::new(context, iterator); 1177 - // while let Some(event) = stream.next().await { 1178 - // self.process_event(event).await?; 1179 - // } 1180 - // 1181 - self.write("markdown embed would go here\n")?; 1182 - } 1183 - EmbedType::Leaflet => { 1184 - self.write("leaflet would go here\n")?; 1185 - } 1186 - EmbedType::Other => { 1187 - self.write("other embed would go here\n")?; 1188 - } 1189 - } 1190 - } 1191 - } else { 1192 - self.write("<iframe src=\"")?; 1193 - escape_href(&mut self.writer, &dest_url)?; 1194 - self.write("\" title=\"")?; 1195 - escape_html(&mut self.writer, &title)?; 1196 - if !id.is_empty() { 1197 - self.write("\" id=\"")?; 1198 - escape_html(&mut self.writer, &id)?; 1199 - self.write("\"")?; 1200 - } 1201 - if let Some(attrs) = attrs { 1202 - self.write(" ")?; 1203 - if !attrs.classes.is_empty() { 1204 - self.write("class=\"")?; 1205 - for class in &attrs.classes { 1206 - escape_html(&mut self.writer, class)?; 1207 - self.write(" ")?; 1208 - } 1209 - self.write("\" ")?; 1210 - } 1211 - if !attrs.attrs.is_empty() { 1212 - for (attr, value) in &attrs.attrs { 1213 - escape_html(&mut self.writer, attr)?; 1214 - self.write("=\"")?; 1215 - escape_html(&mut self.writer, value)?; 1216 - self.write("\" ")?; 1217 - } 1218 - } 1219 - } 1220 - self.write("/>")?; 1221 - } 1222 - Ok(()) 1223 - } 1224 - Tag::WeaverBlock(_, _attrs) => { 1225 - println!("Weaver block"); 1226 - self.in_non_writing_block = true; 1227 - Ok(()) 1228 - } 1229 - Tag::FootnoteDefinition(name) => { 1230 - if self.end_newline { 1231 - self.write("<div class=\"footnote-definition\" id=\"")?; 1232 - } else { 1233 - self.write("\n<div class=\"footnote-definition\" id=\"")?; 1234 - } 1235 - escape_html(&mut self.writer, &name)?; 1236 - self.write("\"><sup class=\"footnote-definition-label\">")?; 1237 - let len = self.numbers.len() + 1; 1238 - let number = *self.numbers.entry(name.into_static()).or_insert(len); 1239 - write!(&mut self.writer, "{}", number)?; 1240 - self.write("</sup>") 1241 - } 1242 - Tag::MetadataBlock(_) => { 1243 - self.in_non_writing_block = true; 1244 - Ok(()) 1245 - } 1246 - } 1247 - } 1248 - 1249 - async fn write_image(&mut self, tag: Tag<'input>) -> Result<(), W::Error> { 1250 - if let Tag::Image { 1251 - link_type: _, 1252 - dest_url, 1253 - title, 1254 - id: _, 1255 - attrs, 1256 - } = tag 1257 - { 1258 - self.write("<img src=\"")?; 1259 - escape_href(&mut self.writer, &dest_url)?; 1260 - if let Some(attrs) = attrs { 1261 - if !attrs.classes.is_empty() { 1262 - self.write("\" class=\"")?; 1263 - for class in &attrs.classes { 1264 - escape_html(&mut self.writer, class)?; 1265 - self.write(" ")?; 1266 - } 1267 - self.write("\" ")?; 1268 - } else { 1269 - self.write("\" ")?; 1270 - } 1271 - if !attrs.attrs.is_empty() { 1272 - for (attr, value) in &attrs.attrs { 1273 - escape_html(&mut self.writer, attr)?; 1274 - self.write("=\"")?; 1275 - escape_html(&mut self.writer, value)?; 1276 - self.write("\" ")?; 1277 - } 1278 - } 1279 - } else { 1280 - self.write("\" ")?; 1281 - } 1282 - self.write("alt=\"")?; 1283 - self.raw_text().await?; 1284 - if !title.is_empty() { 1285 - self.write("\" title=\"")?; 1286 - escape_html(&mut self.writer, &title)?; 1287 - } 1288 - self.write("\" />") 1289 - } else { 1290 - self.write_newline() 1291 - } 1292 - } 1293 - } 1294 - 1295 /// Path lookup in an Obsidian vault 1296 /// 1297 /// Credit to https://github.com/zoni ··· 1364 1365 #[cfg(test)] 1366 mod tests { 1367 use super::*; 1368 use std::path::PathBuf; 1369 use weaver_common::jacquard::client::{ ··· 1378 >; 1379 1380 /// Helper: Create test context without network capabilities 1381 - fn test_context() -> StaticSiteContext<'static, TestSession> { 1382 let root = PathBuf::from("/tmp/test"); 1383 let destination = PathBuf::from("/tmp/output"); 1384 let mut ctx = StaticSiteContext::new(root, destination, None);
··· 5 //! URLs in the notebook are mostly unaltered. It is compatible with GitHub or Cloudflare Pages 6 //! and other similar static hosting services. 7 8 + pub mod context; 9 + pub mod document; 10 + pub mod writer; 11 12 use crate::{ 13 ContextIterator, NotebookProcessor, 14 + static_site::{context::StaticSiteContext, writer::StaticPageWriter}, 15 utils::flatten_dir_to_just_one_parent, 16 walker::{WalkOptions, vault_contents}, 17 }; 18 use bitflags::bitflags; 19 + use markdown_weaver::{BrokenLink, CowStr, Parser}; 20 + use markdown_weaver_escape::FmtWriter; 21 use miette::IntoDiagnostic; 22 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 23 use n0_future::io::AsyncWriteExt; 24 + use std::{ 25 + path::{Path, PathBuf}, 26 + sync::Arc, 27 + }; 28 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 29 use tokio::io::AsyncWriteExt; 30 use unicode_normalization::UnicodeNormalization; 31 + use weaver_common::jacquard::{client::AgentSession, prelude::*}; 32 33 bitflags! { 34 #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] ··· 73 | markdown_weaver::Options::ENABLE_HEADING_ATTRIBUTES 74 } 75 76 + pub struct StaticSiteWriter<A> 77 where 78 A: AgentSession, 79 { 80 + context: StaticSiteContext<A>, 81 } 82 83 + impl<A> StaticSiteWriter<A> 84 where 85 A: AgentSession, 86 { ··· 90 } 91 } 92 93 + impl<A> StaticSiteWriter<A> 94 where 95 + A: AgentSession + IdentityResolver + 'static, 96 { 97 pub async fn run(mut self) -> Result<(), miette::Report> { 98 if !self.context.root.exists() { ··· 141 )); 142 } 143 144 + let mut writers = 145 + Vec::with_capacity(self.context.dir_contents.clone().unwrap_or_default().len()); 146 + 147 for file in self 148 .context 149 .dir_contents ··· 154 .filter(|file| file.starts_with(&self.context.start_at)) 155 { 156 let context = self.context.clone(); 157 + let file = file.clone(); 158 + // we'll see if this is a problem or not 159 + writers.push(n0_future::task::spawn(async move { 160 + let relative_path = file 161 + .strip_prefix(context.start_at.clone()) 162 + .expect("file should always be nested under root") 163 + .to_path_buf(); 164 + if context 165 + .options 166 + .contains(StaticSiteOptions::FLATTEN_STRUCTURE) 167 + { 168 + let path_str = relative_path.to_string_lossy(); 169 + let (parent, file) = flatten_dir_to_just_one_parent(&path_str); 170 + let output_path = context 171 + .destination 172 + .join(String::from(parent)) 173 + .join(String::from(file)); 174 + 175 + write_page(context.clone(), relative_path, output_path).await?; 176 + } else { 177 + let output_path = context.destination.join(relative_path.clone()); 178 179 + write_page(context.clone(), relative_path, output_path).await?; 180 + } 181 + Ok::<(), miette::Report>(()) 182 + })); 183 + } 184 185 + // def want to scope these so we wait until they all complete before we return 186 + // and then we def want the errors, or at least the first error 187 + for fut in n0_future::join_all(writers).await.into_iter() { 188 + fut.into_diagnostic()??; 189 } 190 Ok(()) 191 } 192 } 193 194 + pub async fn export_page<'input, A>( 195 contents: &'input str, 196 + context: StaticSiteContext<A>, 197 ) -> Result<String, miette::Report> 198 where 199 A: AgentSession + IdentityResolver, ··· 216 Ok(output) 217 } 218 219 + pub async fn write_page<A>( 220 + context: StaticSiteContext<A>, 221 input_path: impl AsRef<Path>, 222 output_path: impl AsRef<Path>, 223 ) -> Result<(), miette::Report> ··· 237 Ok(()) 238 } 239 240 /// Path lookup in an Obsidian vault 241 /// 242 /// Credit to https://github.com/zoni ··· 309 310 #[cfg(test)] 311 mod tests { 312 + use crate::NotebookContext; 313 + 314 use super::*; 315 use std::path::PathBuf; 316 use weaver_common::jacquard::client::{ ··· 325 >; 326 327 /// Helper: Create test context without network capabilities 328 + fn test_context() -> StaticSiteContext<TestSession> { 329 let root = PathBuf::from("/tmp/test"); 330 let destination = PathBuf::from("/tmp/output"); 331 let mut ctx = StaticSiteContext::new(root, destination, None);
+454
crates/weaver-renderer/src/static_site/context.rs
···
··· 1 + use crate::static_site::{StaticSiteOptions, default_md_options}; 2 + use crate::theme::Theme; 3 + use crate::{Frontmatter, NotebookContext}; 4 + use dashmap::DashMap; 5 + use markdown_weaver::{CowStr, EmbedType, Tag, WeaverAttributes}; 6 + use std::{ 7 + path::{Path, PathBuf}, 8 + sync::Arc, 9 + }; 10 + use syntect::parsing::SyntaxSet; 11 + use weaver_common::{ 12 + aturi_to_http, 13 + jacquard::{ 14 + client::{Agent, AgentSession, AgentSessionExt}, 15 + prelude::*, 16 + types::blob::MimeType, 17 + }, 18 + }; 19 + use yaml_rust2::Yaml; 20 + 21 + #[derive(Debug, Clone)] 22 + pub enum KaTeXSource { 23 + Cdn, 24 + Local(PathBuf), 25 + } 26 + 27 + pub struct StaticSiteContext<A: AgentSession> { 28 + pub options: StaticSiteOptions, 29 + pub md_options: markdown_weaver::Options, 30 + pub bsky_appview: CowStr<'static>, 31 + pub root: PathBuf, 32 + pub destination: PathBuf, 33 + pub start_at: PathBuf, 34 + pub frontmatter: Arc<DashMap<PathBuf, Frontmatter>>, 35 + pub dir_contents: Option<Arc<[PathBuf]>>, 36 + reference_map: Arc<DashMap<CowStr<'static>, PathBuf>>, 37 + pub titles: Arc<DashMap<PathBuf, CowStr<'static>>>, 38 + pub position: usize, 39 + pub client: Option<reqwest::Client>, 40 + agent: Option<Arc<Agent<A>>>, 41 + 42 + pub theme: Option<Arc<Theme>>, 43 + pub katex_source: Option<KaTeXSource>, 44 + pub syntax_set: Arc<SyntaxSet>, 45 + pub index_file: Option<PathBuf>, 46 + } 47 + 48 + impl<A: AgentSession> Clone for StaticSiteContext<A> { 49 + fn clone(&self) -> Self { 50 + Self { 51 + options: self.options.clone(), 52 + md_options: self.md_options.clone(), 53 + bsky_appview: self.bsky_appview.clone(), 54 + root: self.root.clone(), 55 + destination: self.destination.clone(), 56 + start_at: self.start_at.clone(), 57 + frontmatter: self.frontmatter.clone(), 58 + dir_contents: self.dir_contents.clone(), 59 + reference_map: self.reference_map.clone(), 60 + titles: self.titles.clone(), 61 + position: self.position.clone(), 62 + client: self.client.clone(), 63 + agent: self.agent.clone(), 64 + theme: self.theme.clone(), 65 + katex_source: self.katex_source.clone(), 66 + syntax_set: self.syntax_set.clone(), 67 + index_file: self.index_file.clone(), 68 + } 69 + } 70 + } 71 + 72 + impl<A: AgentSession> StaticSiteContext<A> { 73 + pub fn clone_with_dir_contents(&self, dir_contents: &[PathBuf]) -> Self { 74 + Self { 75 + start_at: self.start_at.clone(), 76 + root: self.root.clone(), 77 + bsky_appview: self.bsky_appview.clone(), 78 + options: self.options.clone(), 79 + md_options: self.md_options.clone(), 80 + frontmatter: self.frontmatter.clone(), 81 + dir_contents: Some(Arc::from(dir_contents)), 82 + destination: self.destination.clone(), 83 + reference_map: self.reference_map.clone(), 84 + titles: self.titles.clone(), 85 + position: self.position, 86 + client: self.client.clone(), 87 + agent: self.agent.clone(), 88 + theme: self.theme.clone(), 89 + katex_source: self.katex_source.clone(), 90 + syntax_set: self.syntax_set.clone(), 91 + index_file: self.index_file.clone(), 92 + } 93 + } 94 + 95 + pub fn clone_with_path(&self, path: impl AsRef<Path>) -> Self { 96 + let position = if let Some(dir_contents) = &self.dir_contents { 97 + dir_contents 98 + .iter() 99 + .position(|p| p == path.as_ref()) 100 + .unwrap_or(0) 101 + } else { 102 + 0 103 + }; 104 + Self { 105 + start_at: self.start_at.clone(), 106 + root: self.root.clone(), 107 + bsky_appview: self.bsky_appview.clone(), 108 + options: self.options.clone(), 109 + md_options: self.md_options.clone(), 110 + frontmatter: self.frontmatter.clone(), 111 + dir_contents: self.dir_contents.clone(), 112 + destination: self.destination.clone(), 113 + reference_map: self.reference_map.clone(), 114 + titles: self.titles.clone(), 115 + position, 116 + client: Some(reqwest::Client::default()), 117 + agent: self.agent.clone(), 118 + theme: self.theme.clone(), 119 + katex_source: self.katex_source.clone(), 120 + syntax_set: self.syntax_set.clone(), 121 + index_file: self.index_file.clone(), 122 + } 123 + } 124 + pub fn new(root: PathBuf, destination: PathBuf, session: Option<A>) -> Self { 125 + Self { 126 + start_at: root.clone(), 127 + root, 128 + bsky_appview: CowStr::Borrowed("deer.social"), 129 + options: StaticSiteOptions::default(), 130 + md_options: default_md_options(), 131 + frontmatter: Arc::new(DashMap::new()), 132 + dir_contents: None, 133 + destination, 134 + reference_map: Arc::new(DashMap::new()), 135 + titles: Arc::new(DashMap::new()), 136 + position: 0, 137 + client: Some(reqwest::Client::default()), 138 + agent: session.map(|session| Arc::new(Agent::new(session))), 139 + theme: None, 140 + katex_source: None, 141 + syntax_set: Arc::new(SyntaxSet::load_defaults_newlines()), 142 + index_file: None, 143 + } 144 + } 145 + 146 + pub fn with_theme(mut self, theme: Theme) -> Self { 147 + self.theme = Some(Arc::new(theme)); 148 + self 149 + } 150 + 151 + pub fn current_path(&self) -> &PathBuf { 152 + if let Some(dir_contents) = &self.dir_contents { 153 + &dir_contents[self.position] 154 + } else { 155 + &self.start_at 156 + } 157 + } 158 + 159 + #[inline] 160 + pub fn handle_link_aturi<'s>(&self, link: Tag<'s>) -> Tag<'s> { 161 + let link = crate::utils::resolve_at_ident_or_uri(&link, &self.bsky_appview); 162 + self.handle_link_normal(link) 163 + } 164 + 165 + pub async fn handle_embed_aturi<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 166 + match &embed { 167 + Tag::Embed { 168 + embed_type, 169 + dest_url, 170 + title, 171 + id, 172 + attrs, 173 + } => { 174 + if dest_url.starts_with("at://") { 175 + let width = if let Some(attrs) = attrs { 176 + let mut width = 600; 177 + for attr in &attrs.attrs { 178 + if attr.0 == CowStr::Borrowed("width".into()) { 179 + width = attr.1.parse::<usize>().unwrap_or(600); 180 + break; 181 + } 182 + } 183 + width 184 + } else { 185 + 600 186 + }; 187 + let html = if let Some(client) = &self.client { 188 + if let Ok(resp) = client 189 + .get("https://embed.bsky.app/oembed") 190 + .query(&[ 191 + ("url", dest_url.clone().into_string()), 192 + ("maxwidth", width.to_string()), 193 + ]) 194 + .send() 195 + .await 196 + { 197 + resp.text().await.ok() 198 + } else { 199 + None 200 + } 201 + } else { 202 + None 203 + }; 204 + if let Some(html) = html { 205 + let link = aturi_to_http(&dest_url, &self.bsky_appview) 206 + .expect("assuming the at-uri is valid rn"); 207 + let mut attrs = if let Some(attrs) = attrs { 208 + attrs.clone() 209 + } else { 210 + WeaverAttributes { 211 + classes: vec![], 212 + attrs: vec![], 213 + } 214 + }; 215 + attrs.attrs.push(("content".into(), html.into())); 216 + Tag::Embed { 217 + embed_type: EmbedType::Comments, // change this when i update markdown-weaver 218 + dest_url: link.into_static(), 219 + title: title.clone(), 220 + id: id.clone(), 221 + attrs: Some(attrs), 222 + } 223 + } else { 224 + self.handle_embed_normal(embed).await 225 + } 226 + } else { 227 + self.handle_embed_normal(embed).await 228 + } 229 + } 230 + _ => embed, 231 + } 232 + } 233 + 234 + pub async fn handle_embed_normal<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 235 + // This option will REALLY slow down iteration over events. 236 + if self.options.contains(StaticSiteOptions::INLINE_EMBEDS) { 237 + match &embed { 238 + Tag::Embed { 239 + embed_type: _, 240 + dest_url, 241 + title, 242 + id, 243 + attrs, 244 + } => { 245 + let mut attrs = if let Some(attrs) = attrs { 246 + attrs.clone() 247 + } else { 248 + WeaverAttributes { 249 + classes: vec![], 250 + attrs: vec![], 251 + } 252 + }; 253 + let contents = if crate::utils::is_local_path(dest_url) { 254 + let file_path = if crate::utils::is_relative_link(dest_url) { 255 + let root_path = self.root.clone(); 256 + root_path.join(Path::new(&dest_url as &str)) 257 + } else { 258 + PathBuf::from(&dest_url as &str) 259 + }; 260 + crate::utils::inline_file(&file_path).await 261 + } else if let Some(client) = &self.client { 262 + if let Ok(resp) = client.get(dest_url.clone().into_string()).send().await { 263 + resp.text().await.ok() 264 + } else { 265 + None 266 + } 267 + } else { 268 + None 269 + }; 270 + if let Some(contents) = contents { 271 + attrs.attrs.push(("content".into(), contents.into())); 272 + Tag::Embed { 273 + embed_type: EmbedType::Markdown, // change this when i update markdown-weaver 274 + dest_url: dest_url.clone(), 275 + title: title.clone(), 276 + id: id.clone(), 277 + attrs: Some(attrs), 278 + } 279 + } else { 280 + embed 281 + } 282 + } 283 + _ => embed, 284 + } 285 + } else { 286 + embed 287 + } 288 + } 289 + 290 + /// This is a no-op for the static site renderer currently. 291 + #[inline] 292 + pub fn handle_link_normal<'s>(&self, link: Tag<'s>) -> Tag<'s> { 293 + link 294 + } 295 + 296 + /// This is a no-op for the static site renderer currently. 297 + #[inline] 298 + pub fn handle_image_normal<'s>(&self, image: Tag<'s>) -> Tag<'s> { 299 + image 300 + } 301 + 302 + pub fn set_options(&mut self, options: StaticSiteOptions) { 303 + self.options = options; 304 + } 305 + } 306 + 307 + impl<A: AgentSession + IdentityResolver> StaticSiteContext<A> { 308 + /// TODO: rework this a bit, to not just do the same thing as whitewind 309 + /// (also need to make a record to refer to them) that being said, doing 310 + /// this with the static site renderer isn't *really* the standard workflow 311 + pub async fn upload_image<'s>(&self, image: Tag<'s>) -> Tag<'s> { 312 + if let Some(agent) = &self.agent { 313 + match &image { 314 + Tag::Image { 315 + link_type, 316 + dest_url, 317 + title, 318 + id, 319 + attrs, 320 + } => { 321 + if crate::utils::is_local_path(&dest_url) { 322 + let root_path = self.root.clone(); 323 + let file_path = root_path.join(Path::new(&dest_url as &str)); 324 + if let Ok(image_data) = std::fs::read(&file_path) { 325 + if let Ok(blob) = agent 326 + .upload_blob(image_data, MimeType::new_static("image/jpg")) 327 + .await 328 + { 329 + let (did, _) = agent.info().await.unwrap(); 330 + let url = weaver_common::blob_url( 331 + &did, 332 + agent.endpoint().await.as_str(), 333 + &blob.r#ref.0, 334 + ); 335 + return Tag::Image { 336 + link_type: *link_type, 337 + dest_url: url.into(), 338 + title: title.clone(), 339 + id: id.clone(), 340 + attrs: attrs.clone(), 341 + }; 342 + } 343 + } 344 + } 345 + } 346 + _ => {} 347 + } 348 + } 349 + image 350 + } 351 + } 352 + 353 + impl<A: AgentSession + IdentityResolver> NotebookContext for StaticSiteContext<A> { 354 + fn set_entry_title(&self, title: CowStr<'_>) { 355 + let path = self.current_path(); 356 + self.titles 357 + .insert(path.clone(), title.clone().into_static()); 358 + self.frontmatter.get_mut(path).map(|frontmatter| { 359 + if let Ok(mut yaml) = frontmatter.yaml.write() { 360 + if yaml.get(0).is_some_and(|y| y.is_hash()) { 361 + let map = yaml.get_mut(0).unwrap().as_mut_hash().unwrap(); 362 + map.insert( 363 + Yaml::String("title".into()), 364 + Yaml::String(title.into_static().into()), 365 + ); 366 + } 367 + } 368 + }); 369 + } 370 + fn entry_title(&self) -> CowStr<'_> { 371 + let path = self.current_path(); 372 + self.titles.get(path).unwrap().clone() 373 + } 374 + 375 + fn frontmatter(&self) -> Frontmatter { 376 + let path = self.current_path(); 377 + self.frontmatter.get(path).unwrap().value().clone() 378 + } 379 + 380 + fn set_frontmatter(&self, frontmatter: Frontmatter) { 381 + let path = self.current_path(); 382 + self.frontmatter.insert(path.clone(), frontmatter); 383 + } 384 + 385 + async fn handle_link<'s>(&self, link: Tag<'s>) -> Tag<'s> { 386 + bitflags::bitflags_match!(self.options, { 387 + // Split this somehow or just combine the options 388 + StaticSiteOptions::RESOLVE_AT_URIS | StaticSiteOptions::RESOLVE_AT_IDENTIFIERS => { 389 + self.handle_link_aturi(link) 390 + } 391 + _ => match &link { 392 + Tag::Link { link_type, dest_url, title, id } => { 393 + if self.options.contains(StaticSiteOptions::FLATTEN_STRUCTURE) { 394 + let (parent, filename) = crate::utils::flatten_dir_to_just_one_parent(&dest_url); 395 + let dest_url = if crate::utils::is_relative_link(&dest_url) 396 + && self.options.contains(StaticSiteOptions::CREATE_CHAPTERS_BY_DIRECTORY) { 397 + if !parent.is_empty() { 398 + CowStr::Boxed(format!("./{}/{}", parent, filename).into_boxed_str()) 399 + } else { 400 + CowStr::Boxed(format!("./{}", filename).into_boxed_str()) 401 + } 402 + } else { 403 + CowStr::Boxed(format!("./entry/{}", filename).into_boxed_str()) 404 + }; 405 + Tag::Link { 406 + link_type: *link_type, 407 + dest_url, 408 + title: title.clone(), 409 + id: id.clone(), 410 + } 411 + } else { 412 + link 413 + 414 + } 415 + }, 416 + _ => link, 417 + } 418 + }) 419 + } 420 + 421 + async fn handle_image<'s>(&self, image: Tag<'s>) -> Tag<'s> { 422 + if self.options.contains(StaticSiteOptions::UPLOAD_BLOBS) { 423 + self.upload_image(image).await 424 + } else { 425 + self.handle_image_normal(image) 426 + } 427 + } 428 + 429 + async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> { 430 + if self.options.contains(StaticSiteOptions::RESOLVE_AT_URIS) 431 + || self.options.contains(StaticSiteOptions::ADD_LINK_PREVIEWS) 432 + { 433 + self.handle_embed_aturi(embed).await 434 + } else { 435 + self.handle_embed_normal(embed).await 436 + } 437 + } 438 + 439 + fn handle_reference(&self, reference: CowStr<'_>) -> CowStr<'_> { 440 + let reference = reference.into_static(); 441 + if let Some(reference) = self.reference_map.get(&reference) { 442 + let path = reference.value().clone(); 443 + CowStr::Boxed(path.to_string_lossy().into_owned().into_boxed_str()) 444 + } else { 445 + reference 446 + } 447 + } 448 + 449 + fn add_reference(&self, reference: CowStr<'_>) { 450 + let path = self.current_path(); 451 + self.reference_map 452 + .insert(reference.into_static(), path.clone()); 453 + } 454 + }
+100
crates/weaver-renderer/src/static_site/document.rs
···
··· 1 + use crate::css::{generate_base_css, generate_syntax_css}; 2 + use crate::static_site::context::{KaTeXSource, StaticSiteContext}; 3 + use crate::theme::Theme; 4 + use miette::IntoDiagnostic; 5 + use weaver_common::jacquard::client::AgentSession; 6 + 7 + #[derive(Debug, Clone, Copy)] 8 + pub enum CssMode { 9 + Linked, 10 + Inline, 11 + } 12 + 13 + pub async fn write_document_head<A: AgentSession>( 14 + context: &StaticSiteContext<A>, 15 + writer: &mut (impl tokio::io::AsyncWrite + Unpin), 16 + css_mode: CssMode, 17 + ) -> miette::Result<()> { 18 + use tokio::io::AsyncWriteExt; 19 + 20 + // Get title from frontmatter or current path 21 + let title = if let Some(path) = context.dir_contents.as_ref() 22 + .and_then(|contents| contents.get(context.position)) 23 + { 24 + context.titles.get(path) 25 + .map(|t| t.value().to_string()) 26 + .unwrap_or_else(|| { 27 + path.file_stem() 28 + .and_then(|s| s.to_str()) 29 + .unwrap_or("Untitled") 30 + .to_string() 31 + }) 32 + } else { 33 + "Untitled".to_string() 34 + }; 35 + 36 + writer.write_all(b"<!DOCTYPE html>\n").await.into_diagnostic()?; 37 + writer.write_all(b"<html lang=\"en\">\n").await.into_diagnostic()?; 38 + writer.write_all(b"<head>\n").await.into_diagnostic()?; 39 + writer.write_all(b" <meta charset=\"utf-8\">\n").await.into_diagnostic()?; 40 + writer.write_all(b" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n").await.into_diagnostic()?; 41 + 42 + // Title 43 + writer.write_all(b" <title>").await.into_diagnostic()?; 44 + writer.write_all(title.as_bytes()).await.into_diagnostic()?; 45 + writer.write_all(b"</title>\n").await.into_diagnostic()?; 46 + 47 + // CSS 48 + match css_mode { 49 + CssMode::Linked => { 50 + writer.write_all(b" <link rel=\"stylesheet\" href=\"/css/base.css\">\n").await.into_diagnostic()?; 51 + writer.write_all(b" <link rel=\"stylesheet\" href=\"/css/syntax.css\">\n").await.into_diagnostic()?; 52 + } 53 + CssMode::Inline => { 54 + let default_theme = Theme::default(); 55 + let theme = context.theme.as_deref().unwrap_or(&default_theme); 56 + 57 + writer.write_all(b" <style>\n").await.into_diagnostic()?; 58 + writer.write_all(generate_base_css(theme).as_bytes()).await.into_diagnostic()?; 59 + writer.write_all(b" </style>\n").await.into_diagnostic()?; 60 + 61 + writer.write_all(b" <style>\n").await.into_diagnostic()?; 62 + let syntax_css = generate_syntax_css(&theme.syntect_theme_name, &context.syntax_set)?; 63 + writer.write_all(syntax_css.as_bytes()).await.into_diagnostic()?; 64 + writer.write_all(b" </style>\n").await.into_diagnostic()?; 65 + } 66 + } 67 + 68 + // KaTeX if enabled 69 + if let Some(ref katex) = context.katex_source { 70 + match katex { 71 + KaTeXSource::Cdn => { 72 + writer.write_all(b" <link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css\">\n").await.into_diagnostic()?; 73 + writer.write_all(b" <script defer src=\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.js\"></script>\n").await.into_diagnostic()?; 74 + writer.write_all(b" <script defer src=\"https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js\" onload=\"renderMathInElement(document.body);\"></script>\n").await.into_diagnostic()?; 75 + } 76 + KaTeXSource::Local(path) => { 77 + let path_str = path.to_string_lossy(); 78 + writer.write_all(format!(" <link rel=\"stylesheet\" href=\"{}/katex.min.css\">\n", path_str).as_bytes()).await.into_diagnostic()?; 79 + writer.write_all(format!(" <script defer src=\"{}/katex.min.js\"></script>\n", path_str).as_bytes()).await.into_diagnostic()?; 80 + writer.write_all(format!(" <script defer src=\"{}/contrib/auto-render.min.js\" onload=\"renderMathInElement(document.body);\"></script>\n", path_str).as_bytes()).await.into_diagnostic()?; 81 + } 82 + } 83 + } 84 + 85 + writer.write_all(b"</head>\n").await.into_diagnostic()?; 86 + writer.write_all(b"<body>\n").await.into_diagnostic()?; 87 + 88 + Ok(()) 89 + } 90 + 91 + pub async fn write_document_footer( 92 + writer: &mut (impl tokio::io::AsyncWrite + Unpin), 93 + ) -> miette::Result<()> { 94 + use tokio::io::AsyncWriteExt; 95 + 96 + writer.write_all(b"</body>\n").await.into_diagnostic()?; 97 + writer.write_all(b"</html>\n").await.into_diagnostic()?; 98 + 99 + Ok(()) 100 + }
+698
crates/weaver-renderer/src/static_site/writer.rs
···
··· 1 + use crate::{NotebookProcessor, base_html::TableState, static_site::context::StaticSiteContext}; 2 + use dashmap::DashMap; 3 + use markdown_weaver::{ 4 + Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 5 + }; 6 + use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 7 + use n0_future::StreamExt; 8 + use weaver_common::jacquard::{client::AgentSession, prelude::*}; 9 + 10 + pub struct StaticPageWriter<'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> 11 + { 12 + context: NotebookProcessor<'input, I, StaticSiteContext<A>>, 13 + writer: W, 14 + /// Whether or not the last write wrote a newline. 15 + end_newline: bool, 16 + 17 + /// Whether if inside a metadata block (text should not be written) 18 + in_non_writing_block: bool, 19 + 20 + table_state: TableState, 21 + table_alignments: Vec<Alignment>, 22 + table_cell_index: usize, 23 + numbers: DashMap<CowStr<'input>, usize>, 24 + 25 + code_buffer: Option<(Option<String>, String)>, // (lang, content) 26 + } 27 + 28 + impl<'input, I: Iterator<Item = Event<'input>>, A: AgentSession, W: StrWrite> 29 + StaticPageWriter<'input, I, A, W> 30 + { 31 + pub fn new(context: NotebookProcessor<'input, I, StaticSiteContext<A>>, writer: W) -> Self { 32 + Self { 33 + context, 34 + writer, 35 + end_newline: true, 36 + in_non_writing_block: false, 37 + table_state: TableState::Head, 38 + table_alignments: vec![], 39 + table_cell_index: 0, 40 + numbers: DashMap::new(), 41 + code_buffer: None, 42 + } 43 + } 44 + 45 + /// Writes a new line. 46 + #[inline] 47 + fn write_newline(&mut self) -> Result<(), W::Error> { 48 + self.end_newline = true; 49 + self.writer.write_str("\n") 50 + } 51 + 52 + /// Writes a buffer, and tracks whether or not a newline was written. 53 + #[inline] 54 + fn write(&mut self, s: &str) -> Result<(), W::Error> { 55 + self.writer.write_str(s)?; 56 + 57 + if !s.is_empty() { 58 + self.end_newline = s.ends_with('\n'); 59 + } 60 + Ok(()) 61 + } 62 + 63 + fn end_tag(&mut self, tag: markdown_weaver::TagEnd) -> Result<(), W::Error> { 64 + use markdown_weaver::TagEnd; 65 + match tag { 66 + TagEnd::HtmlBlock => {} 67 + TagEnd::Paragraph => { 68 + self.write("</p>\n")?; 69 + } 70 + TagEnd::Heading(level) => { 71 + self.write("</")?; 72 + write!(&mut self.writer, "{}", level)?; 73 + self.write(">\n")?; 74 + } 75 + TagEnd::Table => { 76 + self.write("</tbody></table>\n")?; 77 + } 78 + TagEnd::TableHead => { 79 + self.write("</tr></thead><tbody>\n")?; 80 + self.table_state = TableState::Body; 81 + } 82 + TagEnd::TableRow => { 83 + self.write("</tr>\n")?; 84 + } 85 + TagEnd::TableCell => { 86 + match self.table_state { 87 + TableState::Head => { 88 + self.write("</th>")?; 89 + } 90 + TableState::Body => { 91 + self.write("</td>")?; 92 + } 93 + } 94 + self.table_cell_index += 1; 95 + } 96 + TagEnd::BlockQuote(_) => { 97 + self.write("</blockquote>\n")?; 98 + } 99 + TagEnd::CodeBlock => { 100 + if let Some((lang, buffer)) = self.code_buffer.take() { 101 + if let Some(ref lang_str) = lang { 102 + // Use a temporary String buffer for syntect 103 + let mut temp_output = String::new(); 104 + match crate::code_pretty::highlight( 105 + &self.context.context.syntax_set, 106 + Some(lang_str), 107 + &buffer, 108 + &mut temp_output, 109 + ) { 110 + Ok(_) => { 111 + self.write(&temp_output)?; 112 + }, 113 + Err(_) => { 114 + // Fallback to plain code block 115 + self.write("<pre><code class=\"language-")?; 116 + escape_html(&mut self.writer, lang_str)?; 117 + self.write("\">")?; 118 + escape_html_body_text(&mut self.writer, &buffer)?; 119 + self.write("</code></pre>\n")?; 120 + } 121 + } 122 + } else { 123 + self.write("<pre><code>")?; 124 + escape_html_body_text(&mut self.writer, &buffer)?; 125 + self.write("</code></pre>\n")?; 126 + } 127 + } else { 128 + self.write("</code></pre>\n")?; 129 + } 130 + } 131 + TagEnd::List(true) => { 132 + self.write("</ol>\n")?; 133 + } 134 + TagEnd::List(false) => { 135 + self.write("</ul>\n")?; 136 + } 137 + TagEnd::Item => { 138 + self.write("</li>\n")?; 139 + } 140 + TagEnd::DefinitionList => { 141 + self.write("</dl>\n")?; 142 + } 143 + TagEnd::DefinitionListTitle => { 144 + self.write("</dt>\n")?; 145 + } 146 + TagEnd::DefinitionListDefinition => { 147 + self.write("</dd>\n")?; 148 + } 149 + TagEnd::Emphasis => { 150 + self.write("</em>")?; 151 + } 152 + TagEnd::Superscript => { 153 + self.write("</sup>")?; 154 + } 155 + TagEnd::Subscript => { 156 + self.write("</sub>")?; 157 + } 158 + TagEnd::Strong => { 159 + self.write("</strong>")?; 160 + } 161 + TagEnd::Strikethrough => { 162 + self.write("</del>")?; 163 + } 164 + TagEnd::Link => { 165 + self.write("</a>")?; 166 + } 167 + TagEnd::Image => (), // shouldn't happen, handled in start 168 + TagEnd::Embed => (), // shouldn't happen, handled in start 169 + TagEnd::WeaverBlock(_) => { 170 + self.in_non_writing_block = false; 171 + } 172 + TagEnd::FootnoteDefinition => { 173 + self.write("</div>\n")?; 174 + } 175 + TagEnd::MetadataBlock(_) => { 176 + self.in_non_writing_block = false; 177 + } 178 + } 179 + Ok(()) 180 + } 181 + } 182 + 183 + impl<'input, I: Iterator<Item = Event<'input>>, A: AgentSession + IdentityResolver, W: StrWrite> 184 + StaticPageWriter<'input, I, A, W> 185 + { 186 + pub async fn run(mut self) -> Result<(), W::Error> { 187 + while let Some(event) = self.context.next().await { 188 + self.process_event(event).await? 189 + } 190 + Ok(()) 191 + } 192 + 193 + async fn process_event(&mut self, event: Event<'input>) -> Result<(), W::Error> { 194 + use markdown_weaver::Event::*; 195 + match event { 196 + Start(tag) => { 197 + self.start_tag(tag).await?; 198 + } 199 + End(tag) => { 200 + self.end_tag(tag)?; 201 + } 202 + Text(text) => { 203 + // If buffering code, append to buffer instead of writing 204 + if let Some((_, ref mut buffer)) = self.code_buffer { 205 + buffer.push_str(&text); 206 + } else if !self.in_non_writing_block { 207 + escape_html_body_text(&mut self.writer, &text)?; 208 + self.end_newline = text.ends_with('\n'); 209 + } 210 + } 211 + Code(text) => { 212 + self.write("<code>")?; 213 + escape_html_body_text(&mut self.writer, &text)?; 214 + self.write("</code>")?; 215 + } 216 + InlineMath(text) => { 217 + self.write(r#"<span class="math math-inline">"#)?; 218 + escape_html(&mut self.writer, &text)?; 219 + self.write("</span>")?; 220 + } 221 + DisplayMath(text) => { 222 + self.write(r#"<span class="math math-display">"#)?; 223 + escape_html(&mut self.writer, &text)?; 224 + self.write("</span>")?; 225 + } 226 + Html(html) | InlineHtml(html) => { 227 + self.write(&html)?; 228 + } 229 + SoftBreak => { 230 + self.write_newline()?; 231 + } 232 + HardBreak => { 233 + self.write("<br />\n")?; 234 + } 235 + Rule => { 236 + if self.end_newline { 237 + self.write("<hr />\n")?; 238 + } else { 239 + self.write("\n<hr />\n")?; 240 + } 241 + } 242 + FootnoteReference(name) => { 243 + let len = self.numbers.len() + 1; 244 + self.write("<sup class=\"footnote-reference\"><a href=\"#")?; 245 + escape_html(&mut self.writer, &name)?; 246 + self.write("\">")?; 247 + let number = *self.numbers.entry(name.into_static()).or_insert(len); 248 + write!(&mut self.writer, "{}", number)?; 249 + self.write("</a></sup>")?; 250 + } 251 + TaskListMarker(true) => { 252 + self.write("<input disabled=\"\" type=\"checkbox\" checked=\"\"/>\n")?; 253 + } 254 + TaskListMarker(false) => { 255 + self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?; 256 + } 257 + WeaverBlock(_text) => {} 258 + } 259 + Ok(()) 260 + } 261 + 262 + // run raw text, consuming end tag 263 + async fn raw_text(&mut self) -> Result<(), W::Error> { 264 + use markdown_weaver::Event::*; 265 + let mut nest = 0; 266 + while let Some(event) = self.context.next().await { 267 + match event { 268 + Start(_) => nest += 1, 269 + End(_) => { 270 + if nest == 0 { 271 + break; 272 + } 273 + nest -= 1; 274 + } 275 + Html(_) => {} 276 + InlineHtml(text) | Code(text) | Text(text) => { 277 + // Don't use escape_html_body_text here. 278 + // The output of this function is used in the `alt` attribute. 279 + escape_html(&mut self.writer, &text)?; 280 + self.end_newline = text.ends_with('\n'); 281 + } 282 + InlineMath(text) => { 283 + self.write("$")?; 284 + escape_html(&mut self.writer, &text)?; 285 + self.write("$")?; 286 + } 287 + DisplayMath(text) => { 288 + self.write("$$")?; 289 + escape_html(&mut self.writer, &text)?; 290 + self.write("$$")?; 291 + } 292 + SoftBreak | HardBreak | Rule => { 293 + self.write(" ")?; 294 + } 295 + FootnoteReference(name) => { 296 + let len = self.numbers.len() + 1; 297 + let number = *self.numbers.entry(name.into_static()).or_insert(len); 298 + write!(&mut self.writer, "[{}]", number)?; 299 + } 300 + TaskListMarker(true) => self.write("[x]")?, 301 + TaskListMarker(false) => self.write("[ ]")?, 302 + WeaverBlock(_) => { 303 + println!("Weaver block internal"); 304 + } 305 + } 306 + } 307 + Ok(()) 308 + } 309 + 310 + /// Writes the start of an HTML tag. 311 + async fn start_tag(&mut self, tag: Tag<'input>) -> Result<(), W::Error> { 312 + match tag { 313 + Tag::HtmlBlock => Ok(()), 314 + Tag::Paragraph => { 315 + if self.end_newline { 316 + self.write("<p>") 317 + } else { 318 + self.write("\n<p>") 319 + } 320 + } 321 + Tag::Heading { 322 + level, 323 + id, 324 + classes, 325 + attrs, 326 + } => { 327 + if self.end_newline { 328 + self.write("<")?; 329 + } else { 330 + self.write("\n<")?; 331 + } 332 + write!(&mut self.writer, "{}", level)?; 333 + if let Some(id) = id { 334 + self.write(" id=\"")?; 335 + escape_html(&mut self.writer, &id)?; 336 + self.write("\"")?; 337 + } 338 + let mut classes = classes.iter(); 339 + if let Some(class) = classes.next() { 340 + self.write(" class=\"")?; 341 + escape_html(&mut self.writer, class)?; 342 + for class in classes { 343 + self.write(" ")?; 344 + escape_html(&mut self.writer, class)?; 345 + } 346 + self.write("\"")?; 347 + } 348 + for (attr, value) in attrs { 349 + self.write(" ")?; 350 + escape_html(&mut self.writer, &attr)?; 351 + if let Some(val) = value { 352 + self.write("=\"")?; 353 + escape_html(&mut self.writer, &val)?; 354 + self.write("\"")?; 355 + } else { 356 + self.write("=\"\"")?; 357 + } 358 + } 359 + self.write(">") 360 + } 361 + Tag::Table(alignments) => { 362 + self.table_alignments = alignments; 363 + self.write("<table>") 364 + } 365 + Tag::TableHead => { 366 + self.table_state = TableState::Head; 367 + self.table_cell_index = 0; 368 + self.write("<thead><tr>") 369 + } 370 + Tag::TableRow => { 371 + self.table_cell_index = 0; 372 + self.write("<tr>") 373 + } 374 + Tag::TableCell => { 375 + match self.table_state { 376 + TableState::Head => { 377 + self.write("<th")?; 378 + } 379 + TableState::Body => { 380 + self.write("<td")?; 381 + } 382 + } 383 + match self.table_alignments.get(self.table_cell_index) { 384 + Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"), 385 + Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), 386 + Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), 387 + _ => self.write(">"), 388 + } 389 + } 390 + Tag::BlockQuote(kind) => { 391 + let class_str = match kind { 392 + None => "", 393 + Some(kind) => match kind { 394 + BlockQuoteKind::Note => " class=\"markdown-alert-note\"", 395 + BlockQuoteKind::Tip => " class=\"markdown-alert-tip\"", 396 + BlockQuoteKind::Important => " class=\"markdown-alert-important\"", 397 + BlockQuoteKind::Warning => " class=\"markdown-alert-warning\"", 398 + BlockQuoteKind::Caution => " class=\"markdown-alert-caution\"", 399 + }, 400 + }; 401 + if self.end_newline { 402 + self.write(&format!("<blockquote{}>\n", class_str)) 403 + } else { 404 + self.write(&format!("\n<blockquote{}>\n", class_str)) 405 + } 406 + } 407 + Tag::CodeBlock(info) => { 408 + if !self.end_newline { 409 + self.write_newline()?; 410 + } 411 + match info { 412 + CodeBlockKind::Fenced(info) => { 413 + let lang = info.split(' ').next().unwrap(); 414 + let lang_opt = if lang.is_empty() { 415 + None 416 + } else { 417 + Some(lang.to_string()) 418 + }; 419 + // Start buffering 420 + self.code_buffer = Some((lang_opt, String::new())); 421 + Ok(()) 422 + } 423 + CodeBlockKind::Indented => { 424 + // Start buffering with no language 425 + self.code_buffer = Some((None, String::new())); 426 + Ok(()) 427 + } 428 + } 429 + } 430 + Tag::List(Some(1)) => { 431 + if self.end_newline { 432 + self.write("<ol>\n") 433 + } else { 434 + self.write("\n<ol>\n") 435 + } 436 + } 437 + Tag::List(Some(start)) => { 438 + if self.end_newline { 439 + self.write("<ol start=\"")?; 440 + } else { 441 + self.write("\n<ol start=\"")?; 442 + } 443 + write!(&mut self.writer, "{}", start)?; 444 + self.write("\">\n") 445 + } 446 + Tag::List(None) => { 447 + if self.end_newline { 448 + self.write("<ul>\n") 449 + } else { 450 + self.write("\n<ul>\n") 451 + } 452 + } 453 + Tag::Item => { 454 + if self.end_newline { 455 + self.write("<li>") 456 + } else { 457 + self.write("\n<li>") 458 + } 459 + } 460 + Tag::DefinitionList => { 461 + if self.end_newline { 462 + self.write("<dl>\n") 463 + } else { 464 + self.write("\n<dl>\n") 465 + } 466 + } 467 + Tag::DefinitionListTitle => { 468 + if self.end_newline { 469 + self.write("<dt>") 470 + } else { 471 + self.write("\n<dt>") 472 + } 473 + } 474 + Tag::DefinitionListDefinition => { 475 + if self.end_newline { 476 + self.write("<dd>") 477 + } else { 478 + self.write("\n<dd>") 479 + } 480 + } 481 + Tag::Subscript => self.write("<sub>"), 482 + Tag::Superscript => self.write("<sup>"), 483 + Tag::Emphasis => self.write("<em>"), 484 + Tag::Strong => self.write("<strong>"), 485 + Tag::Strikethrough => self.write("<del>"), 486 + Tag::Link { 487 + link_type: LinkType::Email, 488 + dest_url, 489 + title, 490 + id: _, 491 + } => { 492 + self.write("<a href=\"mailto:")?; 493 + escape_href(&mut self.writer, &dest_url)?; 494 + if !title.is_empty() { 495 + self.write("\" title=\"")?; 496 + escape_html(&mut self.writer, &title)?; 497 + } 498 + self.write("\">") 499 + } 500 + Tag::Link { 501 + link_type: _, 502 + dest_url, 503 + title, 504 + id: _, 505 + } => { 506 + self.write("<a href=\"")?; 507 + escape_href(&mut self.writer, &dest_url)?; 508 + if !title.is_empty() { 509 + self.write("\" title=\"")?; 510 + escape_html(&mut self.writer, &title)?; 511 + } 512 + self.write("\">") 513 + } 514 + Tag::Image { 515 + link_type, 516 + dest_url, 517 + title, 518 + id, 519 + attrs, 520 + } => { 521 + self.write_image(Tag::Image { 522 + link_type, 523 + dest_url, 524 + title, 525 + id, 526 + attrs, 527 + }) 528 + .await 529 + } 530 + Tag::Embed { 531 + embed_type, 532 + dest_url, 533 + title, 534 + id, 535 + attrs, 536 + } => { 537 + if let Some(attrs) = attrs { 538 + if let Some((_, content)) = attrs 539 + .attrs 540 + .iter() 541 + .find(|(attr, _)| attr.as_ref() == "content") 542 + { 543 + match embed_type { 544 + EmbedType::Image => { 545 + self.write_image(Tag::Image { 546 + link_type: LinkType::Inline, 547 + dest_url, 548 + title, 549 + id, 550 + attrs: Some(attrs.clone()), 551 + }) 552 + .await? 553 + } 554 + EmbedType::Comments => { 555 + self.write("leaflet would go here\n")?; 556 + } 557 + EmbedType::Post => { 558 + // Bluesky post embed, basically just render the raw html we got 559 + self.write(content)?; 560 + self.write_newline()?; 561 + } 562 + EmbedType::Markdown => { 563 + // let context = self 564 + // .context 565 + // .context 566 + // .clone_with_path(&Path::new(&dest_url.to_string())); 567 + // let callback = 568 + // if let Some(dir_contents) = context.dir_contents.clone() { 569 + // Some(VaultBrokenLinkCallback { 570 + // vault_contents: dir_contents, 571 + // }) 572 + // } else { 573 + // None 574 + // }; 575 + // let parser = Parser::new_with_broken_link_callback( 576 + // &content, 577 + // context.md_options, 578 + // callback, 579 + // ); 580 + // let iterator = ContextIterator::default(parser); 581 + // let mut stream = NotebookProcessor::new(context, iterator); 582 + // while let Some(event) = stream.next().await { 583 + // self.process_event(event).await?; 584 + // } 585 + // 586 + self.write("markdown embed would go here\n")?; 587 + } 588 + EmbedType::Leaflet => { 589 + self.write("leaflet would go here\n")?; 590 + } 591 + EmbedType::Other => { 592 + self.write("other embed would go here\n")?; 593 + } 594 + } 595 + } 596 + } else { 597 + self.write("<iframe src=\"")?; 598 + escape_href(&mut self.writer, &dest_url)?; 599 + self.write("\" title=\"")?; 600 + escape_html(&mut self.writer, &title)?; 601 + if !id.is_empty() { 602 + self.write("\" id=\"")?; 603 + escape_html(&mut self.writer, &id)?; 604 + self.write("\"")?; 605 + } 606 + if let Some(attrs) = attrs { 607 + self.write(" ")?; 608 + if !attrs.classes.is_empty() { 609 + self.write("class=\"")?; 610 + for class in &attrs.classes { 611 + escape_html(&mut self.writer, class)?; 612 + self.write(" ")?; 613 + } 614 + self.write("\" ")?; 615 + } 616 + if !attrs.attrs.is_empty() { 617 + for (attr, value) in &attrs.attrs { 618 + escape_html(&mut self.writer, attr)?; 619 + self.write("=\"")?; 620 + escape_html(&mut self.writer, value)?; 621 + self.write("\" ")?; 622 + } 623 + } 624 + } 625 + self.write("/>")?; 626 + } 627 + Ok(()) 628 + } 629 + Tag::WeaverBlock(_, _attrs) => { 630 + println!("Weaver block"); 631 + self.in_non_writing_block = true; 632 + Ok(()) 633 + } 634 + Tag::FootnoteDefinition(name) => { 635 + if self.end_newline { 636 + self.write("<div class=\"footnote-definition\" id=\"")?; 637 + } else { 638 + self.write("\n<div class=\"footnote-definition\" id=\"")?; 639 + } 640 + escape_html(&mut self.writer, &name)?; 641 + self.write("\"><sup class=\"footnote-definition-label\">")?; 642 + let len = self.numbers.len() + 1; 643 + let number = *self.numbers.entry(name.into_static()).or_insert(len); 644 + write!(&mut self.writer, "{}", number)?; 645 + self.write("</sup>") 646 + } 647 + Tag::MetadataBlock(_) => { 648 + self.in_non_writing_block = true; 649 + Ok(()) 650 + } 651 + } 652 + } 653 + 654 + async fn write_image(&mut self, tag: Tag<'input>) -> Result<(), W::Error> { 655 + if let Tag::Image { 656 + link_type: _, 657 + dest_url, 658 + title, 659 + id: _, 660 + attrs, 661 + } = tag 662 + { 663 + self.write("<img src=\"")?; 664 + escape_href(&mut self.writer, &dest_url)?; 665 + if let Some(attrs) = attrs { 666 + if !attrs.classes.is_empty() { 667 + self.write("\" class=\"")?; 668 + for class in &attrs.classes { 669 + escape_html(&mut self.writer, class)?; 670 + self.write(" ")?; 671 + } 672 + self.write("\" ")?; 673 + } else { 674 + self.write("\" ")?; 675 + } 676 + if !attrs.attrs.is_empty() { 677 + for (attr, value) in &attrs.attrs { 678 + escape_html(&mut self.writer, attr)?; 679 + self.write("=\"")?; 680 + escape_html(&mut self.writer, value)?; 681 + self.write("\" ")?; 682 + } 683 + } 684 + } else { 685 + self.write("\" ")?; 686 + } 687 + self.write("alt=\"")?; 688 + self.raw_text().await?; 689 + if !title.is_empty() { 690 + self.write("\" title=\"")?; 691 + escape_html(&mut self.writer, &title)?; 692 + } 693 + self.write("\" />") 694 + } else { 695 + self.write_newline() 696 + } 697 + } 698 + }
+73
crates/weaver-renderer/src/theme.rs
···
··· 1 + use smol_str::SmolStr; 2 + 3 + #[derive(Debug, Clone)] 4 + pub struct Theme { 5 + pub colors: ColorScheme, 6 + pub fonts: FontScheme, 7 + pub spacing: SpacingScheme, 8 + pub syntect_theme_name: SmolStr, 9 + } 10 + 11 + #[derive(Debug, Clone)] 12 + pub struct ColorScheme { 13 + pub background: SmolStr, 14 + pub foreground: SmolStr, 15 + pub link: SmolStr, 16 + pub link_hover: SmolStr, 17 + } 18 + 19 + #[derive(Debug, Clone)] 20 + pub struct FontScheme { 21 + pub body: SmolStr, 22 + pub heading: SmolStr, 23 + pub monospace: SmolStr, 24 + } 25 + 26 + #[derive(Debug, Clone)] 27 + pub struct SpacingScheme { 28 + pub base_font_size: SmolStr, 29 + pub line_height: SmolStr, 30 + pub scale: SmolStr, 31 + } 32 + 33 + impl Default for Theme { 34 + fn default() -> Self { 35 + Self { 36 + colors: ColorScheme::default(), 37 + fonts: FontScheme::default(), 38 + spacing: SpacingScheme::default(), 39 + syntect_theme_name: SmolStr::new("base16-ocean.dark"), 40 + } 41 + } 42 + } 43 + 44 + impl Default for ColorScheme { 45 + fn default() -> Self { 46 + Self { 47 + background: SmolStr::new("#ffffff"), 48 + foreground: SmolStr::new("#2b303b"), 49 + link: SmolStr::new("#0366d6"), 50 + link_hover: SmolStr::new("#0256b8"), 51 + } 52 + } 53 + } 54 + 55 + impl Default for FontScheme { 56 + fn default() -> Self { 57 + Self { 58 + body: SmolStr::new("system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"), 59 + heading: SmolStr::new("system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"), 60 + monospace: SmolStr::new("'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace"), 61 + } 62 + } 63 + } 64 + 65 + impl Default for SpacingScheme { 66 + fn default() -> Self { 67 + Self { 68 + base_font_size: SmolStr::new("16px"), 69 + line_height: SmolStr::new("1.6"), 70 + scale: SmolStr::new("1.25"), 71 + } 72 + } 73 + }