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