some tests, initial wiring to the cli

Orual 03b64b11 bad927da

+600 -203
+49 -13
Cargo.lock
··· 2301 "jacquard-identity", 2302 "jacquard-oauth", 2303 "jose-jwk", 2304 - "miette", 2305 "regex", 2306 "reqwest", 2307 "serde", ··· 2326 "jacquard-common", 2327 "jacquard-derive", 2328 "jacquard-lexicon", 2329 - "miette", 2330 "rustversion", 2331 "serde", 2332 "serde_ipld_dagcbor", ··· 2345 "jacquard-common", 2346 "jacquard-derive", 2347 "jacquard-identity", 2348 - "miette", 2349 "multibase", 2350 "serde", 2351 "serde_html_form", ··· 2374 "ipld-core", 2375 "k256", 2376 "langtag", 2377 - "miette", 2378 "multibase", 2379 "multihash", 2380 "n0-future", ··· 2423 "jacquard-api", 2424 "jacquard-common", 2425 "jacquard-lexicon", 2426 - "miette", 2427 "moka", 2428 "percent-encoding", 2429 "reqwest", ··· 2448 "heck 0.5.0", 2449 "inventory", 2450 "jacquard-common", 2451 - "miette", 2452 "multihash", 2453 "prettyplease", 2454 "proc-macro2", ··· 2479 "jacquard-identity", 2480 "jose-jwa", 2481 "jose-jwk", 2482 - "miette", 2483 "p256", 2484 "rand 0.8.5", 2485 "rouille", ··· 2618 "ecdsa", 2619 "elliptic-curve", 2620 "sha2", 2621 ] 2622 2623 [[package]] ··· 2866 2867 [[package]] 2868 name = "miette" 2869 version = "7.6.0" 2870 source = "registry+https://github.com/rust-lang/crates.io-index" 2871 checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" ··· 2873 "backtrace", 2874 "backtrace-ext", 2875 "cfg-if", 2876 - "miette-derive", 2877 "owo-colors", 2878 "supports-color", 2879 "supports-hyperlinks", ··· 2882 "terminal_size", 2883 "textwrap", 2884 "unicode-width 0.1.14", 2885 ] 2886 2887 [[package]] ··· 5660 "jacquard-axum", 5661 "jose", 5662 "jose-jwk", 5663 - "miette", 5664 "minijinja", 5665 "minijinja-contrib", 5666 "reqwest", ··· 5689 "dirs", 5690 "jacquard", 5691 "jacquard-api", 5692 - "miette", 5693 "tokio", 5694 "weaver-common", 5695 "weaver-workspace-hack", 5696 ] 5697 ··· 5707 "jose-jwk", 5708 "markdown-weaver", 5709 "markdown-weaver-escape", 5710 - "miette", 5711 "minijinja", 5712 "multibase", 5713 "n0-future", ··· 5744 "insta", 5745 "markdown-weaver", 5746 "markdown-weaver-escape", 5747 - "miette", 5748 "n0-future", 5749 "pathdiff", 5750 "pin-project", ··· 5806 "jacquard-oauth", 5807 "log", 5808 "memchr", 5809 - "miette", 5810 "mime_guess", 5811 "minijinja", 5812 "num-traits",
··· 2301 "jacquard-identity", 2302 "jacquard-oauth", 2303 "jose-jwk", 2304 + "miette 7.6.0", 2305 "regex", 2306 "reqwest", 2307 "serde", ··· 2326 "jacquard-common", 2327 "jacquard-derive", 2328 "jacquard-lexicon", 2329 + "miette 7.6.0", 2330 "rustversion", 2331 "serde", 2332 "serde_ipld_dagcbor", ··· 2345 "jacquard-common", 2346 "jacquard-derive", 2347 "jacquard-identity", 2348 + "miette 7.6.0", 2349 "multibase", 2350 "serde", 2351 "serde_html_form", ··· 2374 "ipld-core", 2375 "k256", 2376 "langtag", 2377 + "miette 7.6.0", 2378 "multibase", 2379 "multihash", 2380 "n0-future", ··· 2423 "jacquard-api", 2424 "jacquard-common", 2425 "jacquard-lexicon", 2426 + "miette 7.6.0", 2427 "moka", 2428 "percent-encoding", 2429 "reqwest", ··· 2448 "heck 0.5.0", 2449 "inventory", 2450 "jacquard-common", 2451 + "miette 7.6.0", 2452 "multihash", 2453 "prettyplease", 2454 "proc-macro2", ··· 2479 "jacquard-identity", 2480 "jose-jwa", 2481 "jose-jwk", 2482 + "miette 7.6.0", 2483 "p256", 2484 "rand 0.8.5", 2485 "rouille", ··· 2618 "ecdsa", 2619 "elliptic-curve", 2620 "sha2", 2621 + ] 2622 + 2623 + [[package]] 2624 + name = "kdl" 2625 + version = "4.7.1" 2626 + source = "registry+https://github.com/rust-lang/crates.io-index" 2627 + checksum = "e03e2e96c5926fe761088d66c8c2aee3a4352a2573f4eaca50043ad130af9117" 2628 + dependencies = [ 2629 + "miette 5.10.0", 2630 + "nom", 2631 + "thiserror 1.0.69", 2632 ] 2633 2634 [[package]] ··· 2877 2878 [[package]] 2879 name = "miette" 2880 + version = "5.10.0" 2881 + source = "registry+https://github.com/rust-lang/crates.io-index" 2882 + checksum = "59bb584eaeeab6bd0226ccf3509a69d7936d148cf3d036ad350abe35e8c6856e" 2883 + dependencies = [ 2884 + "miette-derive 5.10.0", 2885 + "once_cell", 2886 + "thiserror 1.0.69", 2887 + "unicode-width 0.1.14", 2888 + ] 2889 + 2890 + [[package]] 2891 + name = "miette" 2892 version = "7.6.0" 2893 source = "registry+https://github.com/rust-lang/crates.io-index" 2894 checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" ··· 2896 "backtrace", 2897 "backtrace-ext", 2898 "cfg-if", 2899 + "miette-derive 7.6.0", 2900 "owo-colors", 2901 "supports-color", 2902 "supports-hyperlinks", ··· 2905 "terminal_size", 2906 "textwrap", 2907 "unicode-width 0.1.14", 2908 + ] 2909 + 2910 + [[package]] 2911 + name = "miette-derive" 2912 + version = "5.10.0" 2913 + source = "registry+https://github.com/rust-lang/crates.io-index" 2914 + checksum = "49e7bc1560b95a3c4a25d03de42fe76ca718ab92d1a22a55b9b4cf67b3ae635c" 2915 + dependencies = [ 2916 + "proc-macro2", 2917 + "quote", 2918 + "syn 2.0.108", 2919 ] 2920 2921 [[package]] ··· 5694 "jacquard-axum", 5695 "jose", 5696 "jose-jwk", 5697 + "miette 7.6.0", 5698 "minijinja", 5699 "minijinja-contrib", 5700 "reqwest", ··· 5723 "dirs", 5724 "jacquard", 5725 "jacquard-api", 5726 + "kdl", 5727 + "miette 7.6.0", 5728 "tokio", 5729 "weaver-common", 5730 + "weaver-renderer", 5731 "weaver-workspace-hack", 5732 ] 5733 ··· 5743 "jose-jwk", 5744 "markdown-weaver", 5745 "markdown-weaver-escape", 5746 + "miette 7.6.0", 5747 "minijinja", 5748 "multibase", 5749 "n0-future", ··· 5780 "insta", 5781 "markdown-weaver", 5782 "markdown-weaver-escape", 5783 + "miette 7.6.0", 5784 "n0-future", 5785 "pathdiff", 5786 "pin-project", ··· 5842 "jacquard-oauth", 5843 "log", 5844 "memchr", 5845 + "miette 7.6.0", 5846 "mime_guess", 5847 "minijinja", 5848 "num-traits",
+6
crates/weaver-cli/Cargo.toml
··· 5 license.workspace = true 6 publish = false 7 8 [dependencies] 9 clap = { version = "4.5", features = ["derive", "env", "cargo", "unicode"] } 10 weaver-common = { path = "../weaver-common", features = ["native"] } 11 weaver-workspace-hack = { version = "0.1", path = "../weaver-workspace-hack" } 12 miette = { workspace = true, features = ["fancy"] } 13 ··· 16 17 tokio = { version = "1.45.0", features = ["full"] } 18 dirs = "6.0.0"
··· 5 license.workspace = true 6 publish = false 7 8 + [[bin]] 9 + name = "weaver" 10 + path = "src/main.rs" 11 + 12 [dependencies] 13 clap = { version = "4.5", features = ["derive", "env", "cargo", "unicode"] } 14 weaver-common = { path = "../weaver-common", features = ["native"] } 15 + weaver-renderer = { path = "../weaver-renderer" } 16 weaver-workspace-hack = { version = "0.1", path = "../weaver-workspace-hack" } 17 miette = { workspace = true, features = ["fancy"] } 18 ··· 21 22 tokio = { version = "1.45.0", features = ["full"] } 23 dirs = "6.0.0" 24 + kdl = "4.6"
+90 -56
crates/weaver-cli/src/main.rs
··· 1 use jacquard::client::{Agent, FileAuthStore}; 2 - use jacquard::oauth::client::OAuthClient; 3 use jacquard::oauth::loopback::LoopbackConfig; 4 use jacquard::prelude::XrpcClient; 5 use jacquard::types::ident::AtIdentifier; ··· 7 use jacquard_api::app_bsky::actor::get_profile::GetProfile; 8 use miette::{IntoDiagnostic, Result}; 9 use std::path::PathBuf; 10 11 use clap::{Parser, Subcommand}; 12 13 #[derive(Parser)] 14 - #[command(version, about, long_about = None)] 15 #[command(propagate_version = true)] 16 struct Cli { 17 #[command(subcommand)] 18 - command: Commands, 19 } 20 21 #[derive(Subcommand)] ··· 29 #[arg(long)] 30 store: Option<PathBuf>, 31 }, 32 - /// Run a test command with stored auth 33 - Run { 34 - /// Path to auth store file 35 - #[arg(long)] 36 - store: Option<PathBuf>, 37 - }, 38 } 39 40 #[tokio::main] ··· 44 let cli = Cli::parse(); 45 46 match cli.command { 47 - Commands::Auth { handle, store } => { 48 let store_path = store.unwrap_or_else(default_auth_store_path); 49 authenticate(handle, store_path).await?; 50 } 51 - Commands::Run { store } => { 52 - let store_path = store.unwrap_or_else(default_auth_store_path); 53 - run_test(store_path).await?; 54 } 55 } 56 ··· 58 } 59 60 async fn authenticate(handle: String, store_path: PathBuf) -> Result<()> { 61 - println!("Authenticating with {}...", handle); 62 63 let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store_path)); 64 ··· 70 let (did, session_id) = session.session_info().await; 71 72 // Save DID and session_id for later use 73 - let config_path = store_path.with_extension("toml"); 74 - let config_content = format!("did = \"{}\"\nsession_id = \"{}\"\n", did, session_id); 75 std::fs::write(&config_path, config_content).into_diagnostic()?; 76 77 println!("Successfully authenticated!"); 78 println!("Session saved to: {}", store_path.display()); 79 - println!("DID: {}", did); 80 81 Ok(()) 82 } 83 84 - async fn run_test(store_path: PathBuf) -> Result<()> { 85 - println!("Loading session from {}...", store_path.display()); 86 87 - // Read DID and session_id from config 88 - let config_path = store_path.with_extension("toml"); 89 - let config_content = std::fs::read_to_string(&config_path) 90 - .into_diagnostic() 91 - .map_err(|_| miette::miette!("No auth config found. Run 'weaver auth' first."))?; 92 93 - let did_line = config_content 94 - .lines() 95 - .find(|l| l.starts_with("did = ")) 96 - .ok_or_else(|| miette::miette!("Invalid config file"))?; 97 - let session_id_line = config_content 98 - .lines() 99 - .find(|l| l.starts_with("session_id = ")) 100 - .ok_or_else(|| miette::miette!("Invalid config file"))?; 101 102 - let did_str = did_line 103 - .trim_start_matches("did = \"") 104 - .trim_end_matches('"'); 105 - let session_id = session_id_line 106 - .trim_start_matches("session_id = \"") 107 - .trim_end_matches('"'); 108 109 - let did = jacquard::types::string::Did::new(did_str) 110 - .map_err(|_| miette::miette!("Invalid DID in config"))?; 111 112 - let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store_path)); 113 - let session = oauth.restore(&did, session_id).await.into_diagnostic()?; 114 115 - let agent = Agent::from(session); 116 117 - println!("Fetching profile for {}...", did); 118 119 - let profile = agent 120 - .send(GetProfile::new().actor(AtIdentifier::Did(did)).build()) 121 - .await 122 - .into_diagnostic()? 123 - .into_output() 124 - .into_diagnostic()?; 125 126 - println!("\nProfile:"); 127 - println!(" Handle: {}", profile.value.handle); 128 - if let Some(display_name) = &profile.value.display_name { 129 - println!(" Display Name: {}", display_name); 130 } 131 - if let Some(description) = &profile.value.description { 132 - println!(" Description: {}", description); 133 } 134 135 Ok(()) 136 }
··· 1 use jacquard::client::{Agent, FileAuthStore}; 2 + use jacquard::identity::JacquardResolver; 3 + use jacquard::oauth::client::{OAuthClient, OAuthSession}; 4 use jacquard::oauth::loopback::LoopbackConfig; 5 use jacquard::prelude::XrpcClient; 6 use jacquard::types::ident::AtIdentifier; ··· 8 use jacquard_api::app_bsky::actor::get_profile::GetProfile; 9 use miette::{IntoDiagnostic, Result}; 10 use std::path::PathBuf; 11 + use weaver_renderer::static_site::StaticSiteWriter; 12 13 use clap::{Parser, Subcommand}; 14 15 #[derive(Parser)] 16 + #[command(version, about = "Weaver - Static site generator for AT Protocol notebooks", long_about = None)] 17 #[command(propagate_version = true)] 18 struct Cli { 19 + /// Path to notebook directory 20 + source: Option<PathBuf>, 21 + 22 + /// Output directory for static site 23 + dest: Option<PathBuf>, 24 + 25 + /// Path to auth store file 26 + #[arg(long)] 27 + store: Option<PathBuf>, 28 + 29 #[command(subcommand)] 30 + command: Option<Commands>, 31 } 32 33 #[derive(Subcommand)] ··· 41 #[arg(long)] 42 store: Option<PathBuf>, 43 }, 44 } 45 46 #[tokio::main] ··· 50 let cli = Cli::parse(); 51 52 match cli.command { 53 + Some(Commands::Auth { handle, store }) => { 54 let store_path = store.unwrap_or_else(default_auth_store_path); 55 authenticate(handle, store_path).await?; 56 } 57 + None => { 58 + // Render command (default) 59 + let source = cli.source.ok_or_else(|| { 60 + miette::miette!("Source directory required. Usage: weaver <source> <dest>") 61 + })?; 62 + let dest = cli.dest.ok_or_else(|| { 63 + miette::miette!("Destination directory required. Usage: weaver <source> <dest>") 64 + })?; 65 + let store_path = cli.store.unwrap_or_else(default_auth_store_path); 66 + 67 + render_notebook(source, dest, store_path).await?; 68 } 69 } 70 ··· 72 } 73 74 async fn authenticate(handle: String, store_path: PathBuf) -> Result<()> { 75 + println!("Authenticating as @{handle} ..."); 76 77 let oauth = OAuthClient::with_default_config(FileAuthStore::new(&store_path)); 78 ··· 84 let (did, session_id) = session.session_info().await; 85 86 // Save DID and session_id for later use 87 + let config_path = store_path.with_extension("kdl"); 88 + let config_content = format!("did \"{}\"\nsession-id \"{}\"\n", did, session_id); 89 std::fs::write(&config_path, config_content).into_diagnostic()?; 90 91 println!("Successfully authenticated!"); 92 println!("Session saved to: {}", store_path.display()); 93 94 Ok(()) 95 } 96 97 + async fn try_load_session( 98 + store_path: &PathBuf, 99 + ) -> Option<OAuthSession<JacquardResolver, FileAuthStore>> { 100 + use kdl::KdlDocument; 101 102 + // Check if auth store exists 103 + if !store_path.exists() { 104 + return None; 105 + } 106 107 + // Read KDL config 108 + let config_path = store_path.with_extension("kdl"); 109 + let config_content = std::fs::read_to_string(&config_path).ok()?; 110 111 + // Parse KDL 112 + let doc: KdlDocument = config_content.parse().ok()?; 113 + 114 + // Extract did and session-id 115 + let did_node = doc.get("did")?; 116 + let session_id_node = doc.get("session-id")?; 117 118 + let did_str = did_node.entries().first()?.value().as_string()?; 119 + let session_id = session_id_node.entries().first()?.value().as_string()?; 120 121 + // Parse DID 122 + let did = jacquard::types::string::Did::new(did_str).ok()?; 123 124 + // Restore OAuth session 125 + let oauth = OAuthClient::with_default_config(FileAuthStore::new(store_path)); 126 + oauth.restore(&did, session_id).await.ok() 127 + } 128 129 + async fn render_notebook(source: PathBuf, dest: PathBuf, store_path: PathBuf) -> Result<()> { 130 + // Validate source exists 131 + if !source.exists() { 132 + return Err(miette::miette!( 133 + "Source directory not found: {}", 134 + source.display() 135 + )); 136 + } 137 138 + // Try to load session 139 + let session = try_load_session(&store_path).await; 140 141 + // Log auth status 142 + if session.is_some() { 143 + println!("✓ Found authentication"); 144 + } else { 145 + println!("⚠ No authentication found"); 146 + println!(" Run 'weaver auth <handle>' to enable network features"); 147 } 148 + 149 + // Create dest parent directories if needed 150 + if let Some(parent) = dest.parent() { 151 + if !parent.exists() { 152 + std::fs::create_dir_all(parent).into_diagnostic()?; 153 + } 154 } 155 + 156 + // Create renderer 157 + let writer = StaticSiteWriter::new(source, dest.clone(), session); 158 + 159 + // Render 160 + println!("→ Rendering notebook..."); 161 + let start = std::time::Instant::now(); 162 + writer.run().await?; 163 + let elapsed = start.elapsed(); 164 + 165 + // Report success 166 + println!("✓ Rendered in {:.2}s", elapsed.as_secs_f64()); 167 + println!("✓ Output: {}", dest.display()); 168 169 Ok(()) 170 }
+2 -76
crates/weaver-renderer/src/base_html.rs
··· 583 584 /// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and 585 /// push it to a `String`. 586 - /// 587 - /// # Examples 588 - /// 589 - /// ``` 590 - /// use pulldown_cmark::{html, Parser}; 591 - /// 592 - /// let markdown_str = r#" 593 - /// hello 594 - /// ===== 595 - /// 596 - /// * alpha 597 - /// * beta 598 - /// "#; 599 - /// let parser = Parser::new(markdown_str); 600 - /// 601 - /// let mut html_buf = String::new(); 602 - /// html::push_html(&mut html_buf, parser); 603 - /// 604 - /// assert_eq!(html_buf, r#"<h1>hello</h1> 605 - /// <ul> 606 - /// <li>alpha</li> 607 - /// <li>beta</li> 608 - /// </ul> 609 - /// "#); 610 - /// ``` 611 pub fn push_html<'a, I>(s: &mut String, iter: I) 612 where 613 I: Iterator<Item = Event<'a>>, ··· 622 /// will result in poor performance. Wrap these in a 623 /// [`BufWriter`](https://doc.rust-lang.org/std/io/struct.BufWriter.html) to 624 /// prevent unnecessary slowdowns. 625 - /// 626 - /// # Examples 627 - /// 628 - /// ``` 629 - /// use pulldown_cmark::{html, Parser}; 630 - /// use std::io::Cursor; 631 - /// 632 - /// let markdown_str = r#" 633 - /// hello 634 - /// ===== 635 - /// 636 - /// * alpha 637 - /// * beta 638 - /// "#; 639 - /// let mut bytes = Vec::new(); 640 - /// let parser = Parser::new(markdown_str); 641 - /// 642 - /// html::write_html_io(Cursor::new(&mut bytes), parser); 643 - /// 644 - /// assert_eq!(&String::from_utf8_lossy(&bytes)[..], r#"<h1>hello</h1> 645 - /// <ul> 646 - /// <li>alpha</li> 647 - /// <li>beta</li> 648 - /// </ul> 649 - /// "#); 650 - /// ``` 651 //#[cfg(feature = "std")] 652 pub fn write_html_io<'a, I, W>(writer: W, iter: I) -> std::io::Result<()> 653 where ··· 659 660 /// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and 661 /// write it into Unicode-accepting buffer or stream. 662 - /// 663 - /// # Examples 664 - /// 665 - /// ``` 666 - /// use pulldown_cmark::{html, Parser}; 667 - /// 668 - /// let markdown_str = r#" 669 - /// hello 670 - /// ===== 671 - /// 672 - /// * alpha 673 - /// * beta 674 - /// "#; 675 - /// let mut buf = String::new(); 676 - /// let parser = Parser::new(markdown_str); 677 - /// 678 - /// html::write_html_fmt(&mut buf, parser); 679 - /// 680 - /// assert_eq!(buf, r#"<h1>hello</h1> 681 - /// <ul> 682 - /// <li>alpha</li> 683 - /// <li>beta</li> 684 - /// </ul> 685 - /// "#); 686 - /// ``` 687 pub fn write_html_fmt<'a, I, W>(writer: W, iter: I) -> core::fmt::Result 688 where 689 I: Iterator<Item = Event<'a>>,
··· 583 584 /// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and 585 /// push it to a `String`. 586 pub fn push_html<'a, I>(s: &mut String, iter: I) 587 where 588 I: Iterator<Item = Event<'a>>, ··· 597 /// will result in poor performance. Wrap these in a 598 /// [`BufWriter`](https://doc.rust-lang.org/std/io/struct.BufWriter.html) to 599 /// prevent unnecessary slowdowns. 600 + 601 //#[cfg(feature = "std")] 602 pub fn write_html_io<'a, I, W>(writer: W, iter: I) -> std::io::Result<()> 603 where ··· 609 610 /// Iterate over an `Iterator` of `Event`s, generate HTML for each `Event`, and 611 /// write it into Unicode-accepting buffer or stream. 612 + 613 pub fn write_html_fmt<'a, I, W>(writer: W, iter: I) -> core::fmt::Result 614 where 615 I: Iterator<Item = Event<'a>>,
+8
crates/weaver-renderer/src/snapshots/weaver_renderer__static_site__tests__blockquote_rendering.snap
···
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site.rs 3 + expression: output 4 + --- 5 + <blockquote> 6 + <p>This is a quote</p> 7 + <p>With multiple lines</p> 8 + </blockquote>
+8
crates/weaver-renderer/src/snapshots/weaver_renderer__static_site__tests__code_block_rendering.snap
···
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site.rs 3 + expression: output 4 + --- 5 + <pre><code class="language-rust">fn main() { 6 + println!("Hello"); 7 + } 8 + </code></pre>
+7
crates/weaver-renderer/src/snapshots/weaver_renderer__static_site__tests__heading_rendering.snap
···
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site.rs 3 + expression: output 4 + --- 5 + <h1>Heading 1</h1> 6 + <h2>Heading 2</h2> 7 + <h3>Heading 3</h3>
+16
crates/weaver-renderer/src/snapshots/weaver_renderer__static_site__tests__list_rendering.snap
···
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site.rs 3 + expression: output 4 + --- 5 + <ul> 6 + <li>Item 1</li> 7 + <li>Item 2 8 + <ul> 9 + <li>Nested</li> 10 + </ul> 11 + </li> 12 + </ul> 13 + <ol> 14 + <li>Ordered 1</li> 15 + <li>Ordered 2</li> 16 + </ol>
+8
crates/weaver-renderer/src/snapshots/weaver_renderer__static_site__tests__math_rendering.snap
···
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site.rs 3 + expression: output 4 + --- 5 + <p>Inline <span class="math math-inline">x^2</span> and display:</p> 6 + <p><span class="math math-display"> 7 + y = mx + b 8 + </span></p>
+6
crates/weaver-renderer/src/snapshots/weaver_renderer__static_site__tests__paragraph_rendering.snap
···
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site.rs 3 + expression: output 4 + --- 5 + <p>This is a paragraph.</p> 6 + <p>This is another paragraph.</p>
+7
crates/weaver-renderer/src/snapshots/weaver_renderer__static_site__tests__table_rendering.snap
···
··· 1 + --- 2 + source: crates/weaver-renderer/src/static_site.rs 3 + expression: output 4 + --- 5 + <table><thead><tr><th style="text-align: left">Left</th><th style="text-align: center">Center</th><th style="text-align: right">Right</th></tr></thead><tbody> 6 + <tr><td style="text-align: left">A</td><td style="text-align: center">B</td><td style="text-align: right">C</td></tr> 7 + </tbody></table>
+392 -58
crates/weaver-renderer/src/static_site.rs
··· 10 sync::Arc, 11 }; 12 13 - use crate::{ContextIterator, NotebookProcessor, base_html::TableState, walker::WalkOptions}; 14 - use async_trait::async_trait; 15 use bitflags::bitflags; 16 use dashmap::DashMap; 17 use markdown_weaver::{ ··· 22 FmtWriter, StrWrite, escape_href, escape_html, escape_html_body_text, 23 }; 24 use miette::IntoDiagnostic; 25 - use n0_future::StreamExt; 26 #[cfg(all(target_family = "wasm", target_os = "unknown"))] 27 use n0_future::io::AsyncWriteExt; 28 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))] 29 use tokio::io::AsyncWriteExt; 30 use unicode_normalization::UnicodeNormalization; ··· 62 impl Default for StaticSiteOptions { 63 fn default() -> Self { 64 Self::FLATTEN_STRUCTURE 65 - | Self::UPLOAD_BLOBS 66 | Self::RESOLVE_AT_IDENTIFIERS 67 | Self::RESOLVE_AT_URIS 68 | Self::CREATE_INDEX ··· 96 reference_map: Arc<DashMap<CowStr<'a>, PathBuf>>, 97 titles: Arc<DashMap<PathBuf, CowStr<'a>>>, 98 position: usize, 99 - client: reqwest::Client, 100 agent: Option<Arc<Agent<A>>>, 101 } 102 103 impl<A: AgentSession> StaticSiteContext<'_, A> { 104 pub fn clone_with_dir_contents(&self, dir_contents: &[PathBuf]) -> Self { 105 Self { ··· 140 reference_map: self.reference_map.clone(), 141 titles: self.titles.clone(), 142 position, 143 - client: reqwest::Client::default(), 144 agent: self.agent.clone(), 145 } 146 } ··· 157 reference_map: Arc::new(DashMap::new()), 158 titles: Arc::new(DashMap::new()), 159 position: 0, 160 - client: reqwest::Client::default(), 161 agent: session.map(|session| Arc::new(Agent::new(session))), 162 } 163 } ··· 198 } else { 199 600 200 }; 201 - let html = if let Ok(resp) = self 202 - .client 203 - .get("https://embed.bsky.app/oembed") 204 - .query(&[ 205 - ("url", dest_url.clone().into_string()), 206 - ("maxwidth", width.to_string()), 207 - ]) 208 - .send() 209 - .await 210 - { 211 - resp.text().await.ok() 212 } else { 213 None 214 }; ··· 269 PathBuf::from(&dest_url as &str) 270 }; 271 crate::utils::inline_file(&file_path).await 272 - } else if let Some(agent) = &self.agent { 273 - if let Ok(resp) = 274 - self.client.get(dest_url.clone().into_string()).send().await 275 - { 276 resp.text().await.ok() 277 } else { 278 None ··· 314 } 315 316 impl<A: AgentSession + IdentityResolver> StaticSiteContext<'_, A> { 317 pub async fn upload_image<'s>(&self, image: Tag<'s>) -> Tag<'s> { 318 if let Some(agent) = &self.agent { 319 match &image { ··· 400 let (parent, filename) = crate::utils::flatten_dir_to_just_one_parent(&dest_url); 401 let dest_url = if crate::utils::is_relative_link(&dest_url) 402 && self.options.contains(StaticSiteOptions::CREATE_CHAPTERS_BY_DIRECTORY) { 403 - CowStr::Boxed(format!("./{}/{}", parent, filename).into_boxed_str()) 404 } else { 405 CowStr::Boxed(format!("./entry/{}", filename).into_boxed_str()) 406 }; ··· 474 475 impl<'a, A> StaticSiteWriter<'a, A> 476 where 477 - A: AgentSession + IdentityResolver, 478 { 479 - pub async fn run(self) -> Result<(), miette::Report> { 480 - todo!() 481 - } 482 483 - pub async fn export_page<'s, 'input>( 484 - &'s self, 485 - contents: &'input str, 486 - context: StaticSiteContext<'s, A>, 487 - ) -> Result<String, miette::Report> { 488 - let callback = if let Some(dir_contents) = context.dir_contents.clone() { 489 - Some(VaultBrokenLinkCallback { 490 - vault_contents: dir_contents, 491 - }) 492 - } else { 493 - None 494 - }; 495 - let parser = Parser::new_with_broken_link_callback(&contents, context.md_options, callback); 496 - let iterator = ContextIterator::default(parser); 497 - let mut output = String::new(); 498 - let writer = StaticPageWriter::new( 499 - NotebookProcessor::new(context, iterator), 500 - FmtWriter(&mut output), 501 - ); 502 - writer.run().await.into_diagnostic()?; 503 - Ok(output) 504 - } 505 506 - pub async fn write_page(&'a self, path: PathBuf) -> Result<(), miette::Report> { 507 - let contents = tokio::fs::read_to_string(&path).await.into_diagnostic()?; 508 - let mut output_file = crate::utils::create_file(&path).await?; 509 - let context = self.context.clone_with_path(&path); 510 - let output = self.export_page(&contents, context).await?; 511 - output_file 512 - .write_all(output.as_bytes()) 513 - .await 514 - .into_diagnostic()?; 515 Ok(()) 516 } 517 } 518 519 pub struct StaticPageWriter< 520 'a, 521 'input, ··· 1240 } 1241 } 1242 }
··· 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::{ ··· 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; ··· 66 impl Default for StaticSiteOptions { 67 fn default() -> Self { 68 Self::FLATTEN_STRUCTURE 69 + //| Self::UPLOAD_BLOBS 70 | Self::RESOLVE_AT_IDENTIFIERS 71 | Self::RESOLVE_AT_URIS 72 | Self::CREATE_INDEX ··· 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 { ··· 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 } ··· 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 } ··· 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 }; ··· 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 ··· 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 { ··· 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 }; ··· 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() { 513 + return Err(miette::miette!( 514 + "The path specified ({}) does not exist", 515 + self.context.root.display() 516 + )); 517 + } 518 + let contents = vault_contents(&self.context.root, WalkOptions::new())?; 519 + 520 + self.context.dir_contents = Some(contents.into()); 521 + 522 + if self.context.root.is_file() || self.context.start_at.is_file() { 523 + let source_filename = self 524 + .context 525 + .start_at 526 + .file_name() 527 + .expect("wtf how!?") 528 + .to_string_lossy(); 529 + 530 + let dest = if self.context.destination.is_dir() { 531 + self.context.destination.join(String::from(source_filename)) 532 + } else { 533 + let parent = self 534 + .context 535 + .destination 536 + .parent() 537 + .unwrap_or(&self.context.destination); 538 + // Avoid recursively creating self.destination through the call to 539 + // export_note when the parent directory doesn't exist. 540 + if !parent.exists() { 541 + return Err(miette::miette!( 542 + "Destination parent path ({}) does not exist, SOMEHOW", 543 + parent.display() 544 + )); 545 + } 546 + self.context.destination.clone() 547 + }; 548 + return write_page(self.context.clone(), &self.context.start_at, dest).await; 549 + } 550 551 + if !self.context.destination.exists() { 552 + return Err(miette::miette!( 553 + "The destination path specified ({}) does not exist", 554 + self.context.destination.display() 555 + )); 556 + } 557 558 + for file in self 559 + .context 560 + .dir_contents 561 + .as_ref() 562 + .unwrap() 563 + .clone() 564 + .into_iter() 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, 600 + { 601 + let callback = if let Some(dir_contents) = context.dir_contents.clone() { 602 + Some(VaultBrokenLinkCallback { 603 + vault_contents: dir_contents, 604 + }) 605 + } else { 606 + None 607 + }; 608 + let parser = Parser::new_with_broken_link_callback(&contents, context.md_options, callback); 609 + let iterator = ContextIterator::default(parser); 610 + let mut output = String::new(); 611 + let writer = StaticPageWriter::new( 612 + NotebookProcessor::new(context, iterator), 613 + FmtWriter(&mut output), 614 + ); 615 + writer.run().await.into_diagnostic()?; 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> 624 + where 625 + A: AgentSession + IdentityResolver, 626 + { 627 + let contents = tokio::fs::read_to_string(&input_path) 628 + .await 629 + .into_diagnostic()?; 630 + let mut output_file = crate::utils::create_file(output_path.as_ref()).await?; 631 + let context = context.clone_with_path(input_path); 632 + let output = export_page(&contents, context).await?; 633 + output_file 634 + .write_all(output.as_bytes()) 635 + .await 636 + .into_diagnostic()?; 637 + Ok(()) 638 + } 639 + 640 pub struct StaticPageWriter< 641 'a, 642 'input, ··· 1361 } 1362 } 1363 } 1364 + 1365 + #[cfg(test)] 1366 + mod tests { 1367 + use super::*; 1368 + use std::path::PathBuf; 1369 + use weaver_common::jacquard::client::{ 1370 + AtpSession, MemorySessionStore, 1371 + credential_session::{CredentialSession, SessionKey}, 1372 + }; 1373 + 1374 + /// Type alias for the session used in tests 1375 + type TestSession = CredentialSession< 1376 + MemorySessionStore<SessionKey, AtpSession>, 1377 + weaver_common::jacquard::identity::JacquardResolver, 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); 1385 + ctx.client = None; // Explicitly disable network 1386 + ctx 1387 + } 1388 + 1389 + /// Helper: Render markdown to HTML using test context 1390 + async fn render_markdown(input: &str) -> String { 1391 + let context = test_context(); 1392 + export_page(input, context).await.unwrap() 1393 + } 1394 + 1395 + #[tokio::test] 1396 + async fn test_smoke() { 1397 + let output = render_markdown("Hello world").await; 1398 + assert!(output.contains("Hello world")); 1399 + } 1400 + 1401 + #[tokio::test] 1402 + async fn test_paragraph_rendering() { 1403 + let input = "This is a paragraph.\n\nThis is another paragraph."; 1404 + let output = render_markdown(input).await; 1405 + insta::assert_snapshot!(output); 1406 + } 1407 + 1408 + #[tokio::test] 1409 + async fn test_heading_rendering() { 1410 + let input = "# Heading 1\n\n## Heading 2\n\n### Heading 3"; 1411 + let output = render_markdown(input).await; 1412 + insta::assert_snapshot!(output); 1413 + } 1414 + 1415 + #[tokio::test] 1416 + async fn test_list_rendering() { 1417 + let input = "- Item 1\n- Item 2\n - Nested\n\n1. Ordered 1\n2. Ordered 2"; 1418 + let output = render_markdown(input).await; 1419 + insta::assert_snapshot!(output); 1420 + } 1421 + 1422 + #[tokio::test] 1423 + async fn test_code_block_rendering() { 1424 + let input = "```rust\nfn main() {\n println!(\"Hello\");\n}\n```"; 1425 + let output = render_markdown(input).await; 1426 + insta::assert_snapshot!(output); 1427 + } 1428 + 1429 + #[tokio::test] 1430 + async fn test_table_rendering() { 1431 + let input = "| Left | Center | Right |\n|:-----|:------:|------:|\n| A | B | C |"; 1432 + let output = render_markdown(input).await; 1433 + insta::assert_snapshot!(output); 1434 + } 1435 + 1436 + #[tokio::test] 1437 + async fn test_blockquote_rendering() { 1438 + let input = "> This is a quote\n>\n> With multiple lines"; 1439 + let output = render_markdown(input).await; 1440 + insta::assert_snapshot!(output); 1441 + } 1442 + 1443 + #[tokio::test] 1444 + async fn test_math_rendering() { 1445 + let input = "Inline $x^2$ and display:\n\n$$\ny = mx + b\n$$"; 1446 + let output = render_markdown(input).await; 1447 + insta::assert_snapshot!(output); 1448 + } 1449 + 1450 + #[tokio::test] 1451 + async fn test_wikilink_resolution() { 1452 + let vault_contents = vec![ 1453 + PathBuf::from("notes/First Note.md"), 1454 + PathBuf::from("notes/Second Note.md"), 1455 + ]; 1456 + 1457 + let mut context = test_context(); 1458 + context.dir_contents = Some(vault_contents.into()); 1459 + 1460 + let input = "[[First Note]] and [[Second Note]]"; 1461 + let output = export_page(input, context).await.unwrap(); 1462 + println!("{output}"); 1463 + assert!(output.contains("./First%20Note")); 1464 + assert!(output.contains("./Second%20Note")); 1465 + } 1466 + 1467 + #[tokio::test] 1468 + async fn test_broken_wikilink() { 1469 + let vault_contents = vec![PathBuf::from("notes/Exists.md")]; 1470 + 1471 + let mut context = test_context(); 1472 + context.dir_contents = Some(vault_contents.into()); 1473 + 1474 + let input = "[[Does Not Exist]]"; 1475 + let output = export_page(input, context).await.unwrap(); 1476 + 1477 + // Broken wikilinks become links (they just don't point anywhere valid) 1478 + // This is acceptable - static site will show 404 on click 1479 + assert!(output.contains("<a href=")); 1480 + assert!(output.contains("Does Not Exist</a>") || output.contains("Does%20Not%20Exist")); 1481 + } 1482 + 1483 + #[tokio::test] 1484 + async fn test_wikilink_with_section() { 1485 + let vault_contents = vec![PathBuf::from("Note.md")]; 1486 + 1487 + let mut context = test_context(); 1488 + context.dir_contents = Some(vault_contents.into()); 1489 + 1490 + let input = "[[Note#Section]]"; 1491 + let output = export_page(input, context).await.unwrap(); 1492 + println!("{output}"); 1493 + assert!(output.contains("Note#Section")); 1494 + } 1495 + 1496 + #[tokio::test] 1497 + async fn test_link_flattening_enabled() { 1498 + let mut context = test_context(); 1499 + context.options = StaticSiteOptions::FLATTEN_STRUCTURE; 1500 + 1501 + let input = "[Link](path/to/nested/file.md)"; 1502 + let output = export_page(input, context).await.unwrap(); 1503 + println!("{output}"); 1504 + // Should flatten to single parent directory 1505 + assert!(output.contains("./entry/file.md")); 1506 + } 1507 + 1508 + #[tokio::test] 1509 + async fn test_link_flattening_disabled() { 1510 + let mut context = test_context(); 1511 + context.options = StaticSiteOptions::empty(); 1512 + 1513 + let input = "[Link](path/to/nested/file.md)"; 1514 + let output = export_page(input, context).await.unwrap(); 1515 + println!("{output}"); 1516 + // Should preserve original path 1517 + assert!(output.contains("path/to/nested/file.md")); 1518 + } 1519 + 1520 + #[tokio::test] 1521 + async fn test_frontmatter_parsing() { 1522 + let input = "---\ntitle: Test Page\nauthor: Test Author\n---\n\nContent here"; 1523 + let context = test_context(); 1524 + let output = export_page(input, context.clone()).await.unwrap(); 1525 + 1526 + // Frontmatter should be parsed but not rendered 1527 + assert!(!output.contains("title: Test Page")); 1528 + assert!(output.contains("Content here")); 1529 + 1530 + // Verify frontmatter was captured 1531 + let frontmatter = context.frontmatter(); 1532 + let yaml = frontmatter.contents(); 1533 + let yaml_guard = yaml.read().unwrap(); 1534 + assert!(yaml_guard.len() > 0); 1535 + } 1536 + 1537 + #[tokio::test] 1538 + async fn test_empty_frontmatter() { 1539 + let input = "---\n---\n\nContent"; 1540 + let output = render_markdown(input).await; 1541 + 1542 + assert!(output.contains("Content")); 1543 + assert!(!output.contains("---")); 1544 + } 1545 + 1546 + #[tokio::test] 1547 + async fn test_empty_input() { 1548 + let output = render_markdown("").await; 1549 + assert_eq!(output, ""); 1550 + } 1551 + 1552 + #[tokio::test] 1553 + async fn test_html_and_special_characters() { 1554 + // Test that markdown correctly handles HTML and special chars per CommonMark spec 1555 + let input = "Text with <special> & some text. Valid tags: <em>emphasis</em> and <strong>bold</strong>"; 1556 + let output = render_markdown(input).await; 1557 + 1558 + // & must be escaped for valid HTML 1559 + assert!(output.contains("&amp;")); 1560 + 1561 + // Inline HTML tags pass through (CommonMark behavior) 1562 + assert!(output.contains("<special>")); 1563 + assert!(output.contains("<em>emphasis</em>")); 1564 + assert!(output.contains("<strong>bold</strong>")); 1565 + } 1566 + 1567 + #[tokio::test] 1568 + async fn test_unicode_content() { 1569 + let input = "Unicode: 你好 🎉 café"; 1570 + let output = render_markdown(input).await; 1571 + 1572 + assert!(output.contains("你好")); 1573 + assert!(output.contains("🎉")); 1574 + assert!(output.contains("café")); 1575 + } 1576 + }
+1
flake.nix
··· 242 alejandra 243 diesel-cli 244 postgresql 245 jq 246 ]; 247 };
··· 242 alejandra 243 diesel-cli 244 postgresql 245 + cargo-insta 246 jq 247 ]; 248 };