works passably well now, for basic markdown

Orual dd28dcc0 974cc1e0

+324 -35
+237
Jacquard Magic.md
···
··· 1 + # How to make atproto actually easy 2 + 3 + Jacquard is a Rust library, or rather a suite of libraries, intended to make it much simpler to get started with atproto development, without sacrificing flexibility or performance. How it does that is relatively clever, and I think benefits from some explaining, because it doesn't really come across in descriptions like "a better Rust atproto library, with much less boilerplate". Descriptions like those especially don't really communicate that Jacquard is not simpler because someone wrote all the code for you, or had Claude do it. Jacquard is simpler because it is designed in a way which makes things simple that almost every other atproto library seems to make difficult. 4 + 5 + >![Image of a Jacquard loom](https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEiSPs0tGvet_KoxRdIGwq5j8JGIzDQV70bGfrKPrMnBm3JTRB-xKmusyu1rkCQapv-cYbkmwsPzkrvphkuikoZ2wK71LZVRLatpgesY4yrrBU_zZWLSzDQe9FH-STcOTZBlmvlvHA/w1200-h630-p-k-no-nu/device.jpg) The [Jacquard machine](https://en.wikipedia.org/wiki/Jacquard_machine) was one of the earliest devices you might call "programmable" in the sense we normally mean, allowing a series of punched cards to automatically control a mechanical weaving loom. 6 + 7 + First, let's talk boilerplate. An extremely common thing for people writing code for atproto to have to do is to write friendly helper methods over API bindings generated from lexicons. In the official Bluesky Typescript library you get a couple of layers of `**Agent` wrapper classes which provide convenient helpers for common methods, mostly hand-written, because the autogenerated API bindings are verbose to call and don't necessarily handle all the eventualities. There is a *lot* of code dedicated to handling updates to Bluesky preferences. Among the worst for required boilerplate is ATrium, the most widely-used set of Rust libraries for atproto, which mirrors the Typescript SDK in many ways, not all good. This results in pretty much anyone using ATrium needing to implement their own more ergonomic helpers, and often reimplementing chunks of the library for things like session management (particularly if they want to use their own lexicons), because certain important internal types aren't exported. This is boilerplate, and while LLMs are often pretty good at doing that for you these days, it still clutters your codebase. 8 + 9 + The problem with needing handwritten helpers to do things conveniently is that when you venture off the beaten path you end up needing to reinvent the wheel a lot. This is a big barrier for people looking to "just do things" on atproto. You need to figure out OAuth, you need to write all those convenience functions, etc. especially if you're working with your own lexicons rather than just using Bluesky's. 10 + 11 + >There are other libraries which handle some of these things better, but nothing (especially not in Rust) which got all the way there in a way that fit how I like to work, and how I think a lot of other Rust developers would like to work. Jacquard is the answer to the question a lot of my Rust atproto developer friends were asking. 12 + 13 + Here's the canonical example. Compare to the ATrium [Bluesky SDK](https://docs.rs/bsky-sdk/latest/bsky_sdk/index.html#moderation) example, which doesn't handle OAuth. There are some convenient helpers used here to elide OAuth setup stuff (helpers which [ATrium's OAuth implementation](https://github.com/atrium-rs/atrium/blob/main/atrium-oauth/README.md) lacks) but even [without those](https://tangled.org/@nonbinary.computer/jacquard/blob/main/examples/oauth_timeline.rs), [it's](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-oauth/src/loopback.rs) [not](https://docs.rs/jacquard-oauth/0.4.0/jacquard_oauth/atproto/struct.AtprotoClientMetadata.html) [that](https://docs.rs/jacquard-oauth/0.4.0/jacquard_oauth/client/struct.OAuthClient.html) [verbose](https://docs.rs/jacquard-oauth/0.4.0/jacquard_oauth/client/struct.OAuthSession.html), and the actual main action, fetching the timeline, is simply calling a single function with a generated API struct, then handling the result. Nothing here is Bluesky-specific that wasn't generated in seconds by Jacquard's lexicon API [code generation](https://tangled.org/@nonbinary.computer/jacquard/tree/main/crates/jacquard-lexicon). 14 + 15 + ```rust 16 + #[tokio::main] 17 + async fn main() -> miette::Result<()> { 18 + let args = Args::parse(); 19 + // Build an OAuth client with file-backed auth store and default localhost config 20 + let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); 21 + // Authenticate with a PDS, using a loopback server to handle the callback flow 22 + let session = oauth 23 + .login_with_local_server( 24 + args.input.clone(), 25 + Default::default(), 26 + LoopbackConfig::default(), 27 + ) 28 + .await?; 29 + // Wrap in Agent and fetch the timeline 30 + let agent: Agent<_> = Agent::from(session); 31 + let timeline = agent 32 + .send(GetTimeline::new().limit(5).build()) 33 + .await? 34 + .into_output()?; 35 + for (i, post) in timeline.feed.iter().enumerate() { 36 + println!("\n{}. by @{}", i + 1, post.post.author.handle); 37 + println!( 38 + " {}", 39 + serde_json::to_string_pretty(&post.post.record).into_diagnostic()? 40 + ); 41 + } 42 + Ok(()) 43 + } 44 + ``` 45 + ## Just `.send()` it 46 + 47 + Jacquard has a couple of `.send()` methods. One is stateless. it's the output of a method that creates a request builder, implemented as an extension trait, `XrpcExt`, on any http client which implements a very simple HttpClient trait. You can use a bare `reqwest::Client` to make XRPC requests. You call `.xrpc(base_url)` and get an `XrpcCall` struct. `XrpcCall` is a builder, which allows you to pass authentication, atproto proxy settings, labeler headings, and set other options for the final request. There's also a similar trait `DpopExt` in the `jacquard-oauth` crate, which handles that form of authenticated request in a similar way. For basic stuff, this works great, and it's a useful building block for more complex logic, or when one size does **not** in fact fit all. 48 + 49 + ```rust 50 + use jacquard_common::xrpc::XrpcExt; 51 + use jacquard_common::http_client::HttpClient; 52 + /// ... 53 + let http = reqwest::Client::new(); 54 + let base = url::Url::parse("https://public.api.bsky.app")?; 55 + let resp = http.xrpc(base).send(&request).await?; 56 + ``` 57 + The other, `XrpcClient`, is stateful, and can be implemented on anything with a bit of internal state to store the base URI (the URL of the PDS being contacted) and the default options. It's the one you're most likely to interact with doing normal atproto API client stuff. The Agent struct in the initial example implements that trait, as does the session struct it wraps, and the `.send()` method used is that trait method. 58 + 59 + >`XrpcClient` implementers don't *have* to implement token auto-refresh and so on, but realistically they *should* implement at least a basic version. There is an `AgentSession` trait which does require full session/state management. 60 + 61 + Here is the entire text of `XrpcCall::send()`. [`build_http_request()`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-common/src/xrpc.rs#L400) and [`process_response()`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-common/src/xrpc.rs#L344) are public functions and can be used in other crates. The first does more or less what it says on the tin. The second does less than you might think. It mostly surfaces authentication errors at an earlier level so you don't have to fully parse the response to know if there was an error or not. 62 + 63 + ```rust 64 + pub async fn send<R>( 65 + self, 66 + request: &R, 67 + ) -> XrpcResult<Response<<R as XrpcRequest<'s>>::Response>> 68 + where 69 + R: XrpcRequest, 70 + { 71 + let http_request = build_http_request(&self.base, request, &self.opts) 72 + .map_err(TransportError::from)?; 73 + let http_response = self 74 + .client 75 + .send_http(http_request) 76 + .await 77 + .map_err(|e| TransportError::Other(Box::new(e)))?; 78 + process_response(http_response) 79 + } 80 + ``` 81 + >A core goal of Jacquard is to not only provide an easy interface to atproto, but to also make it very easy to build something that fits your needs, and making "helper" functions like those part of the API surface is a big part of that, as are "stateless" implementations like `XrpcExt` and `XrpcCall`. 82 + 83 + `.send()` works for any endpoint and any type that implements the required traits, regardless of what crate it's defined in. There's no `KnownRecords` enum which defines a complete set of known records, and no restriction of Service endpoints in the agent/client, or anything like that, nothing that privileges any set of lexicons or way of working with the library, as much as possible. There's one primary method and you can put pretty much anything relevant into it. Whatever atproto API you need to call, just `.send()` it. Okay there are a couple of additional helpers, but we're focusing on the core one, because pretty much everything else is just wrapping the above `send()` in one way or another, and they use the same pattern. 84 + 85 + ## Punchcard Instructions 86 + 87 + So how does this work? How does `send()` and its helper functions know what to do? The answer shouldn't be surprising to anyone familiar with Rust. It's traits! Specifically, the following traits, which have generated implementations for every lexicon type ingested by Jacquard's API code generation, but which honestly aren't hard to just implement yourself (more tedious than anything). XrpcResp is always implemented on a unit/marker struct with no fields. They provide all the request-specific instructions to the functions. 88 + 89 + ```rust 90 + pub trait XrpcRequest: Serialize { 91 + const NSID: &'static str; 92 + /// XRPC method (query/GET or procedure/POST) 93 + const METHOD: XrpcMethod; 94 + type Response: XrpcResp; 95 + /// Encode the request body for procedures. 96 + fn encode_body(&self) -> Result<Vec<u8>, EncodeError> { 97 + Ok(serde_json::to_vec(self)?) 98 + } 99 + /// Decode the request body for procedures. (Used server-side) 100 + fn decode_body<'de>(body: &'de [u8]) -> Result<Box<Self>, DecodeError> 101 + where 102 + Self: Deserialize<'de> 103 + { 104 + let body: Self = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?; 105 + Ok(Box::new(body)) 106 + } 107 + } 108 + pub trait XrpcResp { 109 + const NSID: &'static str; 110 + /// Output encoding (MIME type) 111 + const ENCODING: &'static str; 112 + type Output<'de>: Deserialize<'de> + IntoStatic; 113 + type Err<'de>: Error + Deserialize<'de> + IntoStatic; 114 + } 115 + ``` 116 + Here are the implementations for [`GetTimeline`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/get_timeline.rs). You'll also note that `send()` doesn't return the fully decoded response on success. It returns a Response struct which has a generic parameter that must implement the XrpcResp trait above. Here's its definition. It's essentially just a cheaply cloneable byte buffer and a type marker. 117 + 118 + ```rust 119 + pub struct Response<R: XrpcResp> { 120 + buffer: Bytes, 121 + status: StatusCode, 122 + _marker: PhantomData<R>, 123 + } 124 + 125 + impl<R: XrpcResp> Response<R> { 126 + pub fn parse<'s>( 127 + &'s self 128 + ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 129 + // Borrowed parsing into Output or Err 130 + } 131 + pub fn into_output( 132 + self 133 + ) -> Result<<Resp as XrpcResp>::Output<'static>, XrpcError<<Resp as XrpcResp>::Err<'static>>> 134 + where ... 135 + { /* Owned parsing into Output or Err */ } 136 + } 137 + ``` 138 + You decode the response (or the endpoint-specific error) out of this, borrowing from the buffer or taking ownership so you can drop the buffer. There are two reasons for this. One is separation of concerns. By two-staging the parsing, it's easier to distinguish network and authentication problems from application-level errors. The second is lifetimes and borrowed deserialization. This is a bit of a long, technical aside, so if you want to jump over it, skip down to "**So What?**" 139 + 140 + --- 141 + 142 + ### Working with Lifetimes and Zero-Copy Deserialization 143 + 144 + Jacquard is designed around zero-copy/borrowed deserialization: types like [`Post<'a>`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/post.rs) can borrow strings and other data directly from the response buffer instead of allocating owned copies. This is great for performance, but it creates some interesting challenges, especially in async contexts. So how do you specify the lifetime of the borrow? 145 + 146 + The naive approach would be to put a lifetime parameter on the trait itself: 147 + 148 + ```rust 149 + 150 + trait NaiveXrpcRequest<'de> { 151 + type Output: Deserialize<'de>; 152 + // ... 153 + } 154 + ``` 155 + 156 + This looks reasonable until you try to use it in a generic context. If you have a function that works with *any* lifetime, you need a Higher-ranked trait bound: 157 + 158 + ```rust 159 + fn parse<R>(response: &[u8]) ... // return type 160 + where 161 + R: for<'any> XrpcRequest<'any> 162 + { /* deserialize from response... */ } 163 + ``` 164 + 165 + The `for<'any>` bound says "this type must implement `XrpcRequest` for *every possible lifetime*", which, for `Deserialize`, is effectively the same as requiring `DeserializeOwned`. You've probably just thrown away your zero-copy optimization, and furthermore that trait bound just straight-up won't work on most of the types in Jacquard. The vast majority of them have either a custom Deserialize implementation which will borrow if it can, a `#[serde(borrow)]` attribute on one or more fields, or an equivalent lifetime bound attribute, associated with the Deserialize derive macro. You will get "Deserialize implementation not general enough" if you try. And no, you cannot have an additional deserialize implementation for the `'static` lifetime due to how serde works. 166 + 167 + If you instead try something like the below function signature and specify a specific lifetime, it will compile in isolation, but when you go to use it, the Rust compiler will not generally be able to figure out the lifetimes at the call site, and will complain about things being dropped while still borrowed, even if you convert the response to an owned/ `'static` lifetime version of the type. 168 + 169 + ```rust 170 + fn parse<'s, R: XrpcRequest<'s>>(response: &'s [u8]) ... // return type with the same lifetime 171 + { /* deserialize from response... */ } 172 + ``` 173 + 174 + It gets worse with async. If you want to return borrowed data from an async method, where does the lifetime come from? The response buffer needs to outlive the borrow, but the buffer is consumed or potentially has to have an unbounded lifetime. You end up with confusing and frustrating errors because the compiler can't prove the buffer will stay alive or that you have taken ownership of the parts of it you care about. And even if you don't return borrowed data, holding anything across an await point makes determining bounds for things like the Send autotrait (important if you're working with crates like Axum) impossible for the compiler. You *could* do some lifetime laundering with `unsafe`, but that road leads to potential soundness issues, and besides, you don't actually *need* to tell `rustc` to "trust me, bro", you can, with some cleverness, explain this to the compiler in a way that it can reason about perfectly well. 175 + 176 + #### Explaining where the buffer goes to `rustc` 177 + 178 + The fix is to use Generic Associated Types (GATs) on the trait's associated types, while keeping the trait itself lifetime-free: 179 + 180 + ```rust 181 + pub trait XrpcResp { 182 + const NSID: &'static str; 183 + /// Output encoding (MIME type) 184 + const ENCODING: &'static str; 185 + type Output<'de>: Deserialize<'de> + IntoStatic; 186 + type Err<'de>: Error + Deserialize<'de> + IntoStatic; 187 + } 188 + ``` 189 + 190 + Now you can write trait bounds without HRTBs, and with lifetime bounds that are actually possible for Jacquard's borrowed deserializing types to meet: 191 + 192 + ```rust 193 + fn parse<'s, R: XrpcResp>(response: &'s [u8]) /* return type with same lifetime */ { 194 + // Compiler can pick a concrete lifetime for R::Output<'_> or have it specified easily 195 + } 196 + ``` 197 + 198 + Methods that need lifetimes use method-level generic parameters: 199 + 200 + ```rust 201 + // This is part of a trait from jacquard itself, used to genericize updates to things like the Bluesky 202 + // preferences union, so that if you implement a similar lexicon type in your app, you don't have 203 + // to special-case it. Instead you can do a relatively simple trait implementation and then call 204 + // .update_vec() with a modifier function or .update_vec_item() with a single item you want to set. 205 + 206 + pub trait VecUpdate { 207 + type GetRequest: XrpcRequest; 208 + type PutRequest: XrpcRequest; 209 + // ... more stuff 210 + 211 + // Method-level lifetime, not trait-level 212 + fn extract_vec<'s>( 213 + output: <Self::GetRequest<'s> as XrpcRequest<'s>>::Output<'s> 214 + ) -> Vec<Self::Item>; 215 + // ... more stuff 216 + } 217 + ``` 218 + 219 + The compiler can monomorphize for concrete lifetimes instead of trying to prove bounds hold for *all* lifetimes at once, or struggle to figure out when you're done with a buffer. `XrpcResp` being separate and lifetime-free lets async methods like `.send()` return a `Response` that owns the response buffer, and then the *caller* decides the lifetime strategy: 220 + 221 + ```rust 222 + // Zero-copy: borrow from the owned buffer 223 + let output: R::Output<'_> = response.parse()?; 224 + 225 + // Owned: convert to 'static via IntoStatic 226 + let output: R::Output<'static> = response.into_output()?; 227 + ``` 228 + 229 + The async method doesn't need to know or care about lifetimes for the most part - it just returns the `Response`. The caller gets full control over whether to use borrowed or owned data. It can even decide after the fact that it doesn't want to parse out the API response type that it asked for. Instead it can call `.parse_data()` or `.parse_raw()` on the response to get loosely typed, validated data or minimally typed maximally accepting data values out. 230 + 231 + ## So what? 232 + 233 + Well, most importantly, what this means is that people using Jacquard have to write a lot less code, and I developing Jacquard also have to write a lot less code to support a wide variety of use cases. Jacquard's code generation handles all the trait implementation housekeeping and marker structs for `jacquard-api` and for the most part you can just use the generated stuff as is. It also means that even if you don't care about zero-copy deserialization or strong typing and just want things to be easy, things are in fact easy. Just put `'static` for your lifetime bounds on potentially borrowed Jacquard types, derive `IntoStatic` and call `.into_static()` to take ownership if needed, and forget about it. Use atproto string types like they're strings. Use loosely typed data values that actually know about atproto primitives like `at://` uris or DIDs, handles, CIDs or blobs rather than just `serde_json::Value` or `ipld_core::ipld::Ipld`. And if you're working with posts from, for example, [Bridgy Fed](https://fed.brid.gy/), which injects extra fields which aren't in the official Bluesky lexicon that carry the original ActivityPub data into federated Mastodon posts, you can access those fields easily via the `extra_data` field that the `#[lexicon]` attribute macro adds to record types. 234 + 235 + So yeah. If you're writing atproto stuff in Rust, and you don't need stuff that's not implemented yet (like moderation filtering and easy service auth), consider using Jacquard. It's pretty cool. I just released version 0.5.0, which has a number of nice additions and improves the documentation a fair bit. There are a number of [examples](https://tangled.org/@nonbinary.computer/jacquard/tree/main/examples) in the Tangled repository. 236 + 237 + And if you got this far and like the library, I do accept [sponsorships](https://github.com/sponsors/orual) on GitHub.
+57 -11
crates/weaver-renderer/src/atproto/writer.rs
··· 38 numbers: HashMap<String, usize>, 39 40 embed_provider: Option<E>, 41 } 42 43 #[derive(Debug, Clone, Copy)] ··· 57 table_cell_index: 0, 58 numbers: HashMap::new(), 59 embed_provider: None, 60 } 61 } 62 ··· 71 table_cell_index: self.table_cell_index, 72 numbers: self.numbers, 73 embed_provider: Some(provider), 74 } 75 } 76 } ··· 105 Start(tag) => self.start_tag(tag)?, 106 End(tag) => self.end_tag(tag)?, 107 Text(text) => { 108 - if !self.in_non_writing_block { 109 escape_html_body_text(&mut self.writer, &text)?; 110 self.end_newline = text.ends_with('\n'); 111 } ··· 254 } 255 match info { 256 CodeBlockKind::Fenced(info) => { 257 - let lang = info.split(' ').next().unwrap_or(""); 258 - if !lang.is_empty() { 259 - self.write("<pre><code class=\"language-")?; 260 - escape_html(&mut self.writer, lang)?; 261 - self.write("\">")?; 262 } else { 263 - self.write("<pre><code>")?; 264 - } 265 } 266 CodeBlockKind::Indented => { 267 - self.write("<pre><code>")?; 268 } 269 } 270 - Ok(()) 271 } 272 Tag::List(Some(1)) => { 273 if self.end_newline { ··· 442 Ok(()) 443 } 444 TagEnd::BlockQuote(_) => self.write("</blockquote>\n"), 445 - TagEnd::CodeBlock => self.write("</code></pre>\n"), 446 TagEnd::List(true) => self.write("</ol>\n"), 447 TagEnd::List(false) => self.write("</ul>\n"), 448 TagEnd::Item => self.write("</li>\n"),
··· 38 numbers: HashMap<String, usize>, 39 40 embed_provider: Option<E>, 41 + 42 + code_buffer: Option<(Option<String>, String)>, // (lang, content) 43 } 44 45 #[derive(Debug, Clone, Copy)] ··· 59 table_cell_index: 0, 60 numbers: HashMap::new(), 61 embed_provider: None, 62 + code_buffer: None, 63 } 64 } 65 ··· 74 table_cell_index: self.table_cell_index, 75 numbers: self.numbers, 76 embed_provider: Some(provider), 77 + code_buffer: self.code_buffer, 78 } 79 } 80 } ··· 109 Start(tag) => self.start_tag(tag)?, 110 End(tag) => self.end_tag(tag)?, 111 Text(text) => { 112 + // If buffering code, append to buffer instead of writing 113 + if let Some((_, ref mut buffer)) = self.code_buffer { 114 + buffer.push_str(&text); 115 + } else if !self.in_non_writing_block { 116 escape_html_body_text(&mut self.writer, &text)?; 117 self.end_newline = text.ends_with('\n'); 118 } ··· 261 } 262 match info { 263 CodeBlockKind::Fenced(info) => { 264 + let lang = info.split(' ').next().unwrap(); 265 + let lang_opt = if lang.is_empty() { 266 + None 267 } else { 268 + Some(lang.to_string()) 269 + }; 270 + // Start buffering 271 + self.code_buffer = Some((lang_opt, String::new())); 272 + Ok(()) 273 } 274 CodeBlockKind::Indented => { 275 + // Start buffering with no language 276 + self.code_buffer = Some((None, String::new())); 277 + Ok(()) 278 } 279 } 280 } 281 Tag::List(Some(1)) => { 282 if self.end_newline { ··· 451 Ok(()) 452 } 453 TagEnd::BlockQuote(_) => self.write("</blockquote>\n"), 454 + TagEnd::CodeBlock => { 455 + use std::sync::LazyLock; 456 + use syntect::parsing::SyntaxSet; 457 + static SYNTAX_SET: LazyLock<SyntaxSet> = 458 + LazyLock::new(|| SyntaxSet::load_defaults_newlines()); 459 + 460 + if let Some((lang, buffer)) = self.code_buffer.take() { 461 + if let Some(ref lang_str) = lang { 462 + // Use a temporary String buffer for syntect 463 + let mut temp_output = String::new(); 464 + match crate::code_pretty::highlight( 465 + &SYNTAX_SET, 466 + Some(lang_str), 467 + &buffer, 468 + &mut temp_output, 469 + ) { 470 + Ok(_) => { 471 + self.write(&temp_output)?; 472 + } 473 + Err(_) => { 474 + // Fallback to plain code block 475 + self.write("<pre><code class=\"language-")?; 476 + escape_html(&mut self.writer, lang_str)?; 477 + self.write("\">")?; 478 + escape_html_body_text(&mut self.writer, &buffer)?; 479 + self.write("</code></pre>\n")?; 480 + } 481 + } 482 + } else { 483 + self.write("<pre><code>")?; 484 + escape_html_body_text(&mut self.writer, &buffer)?; 485 + self.write("</code></pre>\n")?; 486 + } 487 + } else { 488 + self.write("</code></pre>\n")?; 489 + } 490 + Ok(()) 491 + } 492 TagEnd::List(true) => self.write("</ol>\n"), 493 TagEnd::List(false) => self.write("</ul>\n"), 494 TagEnd::Item => self.write("</li>\n"),
+14 -5
crates/weaver-renderer/src/css.rs
··· 50 font-family: var(--font-body); 51 color: var(--color-foreground); 52 background-color: var(--color-background); 53 - max-width: 65ch; 54 margin: 0 auto; 55 padding: 2rem 1rem; 56 }} ··· 64 }} 65 66 h1 {{ 67 - font-size: 2.5rem; 68 color: var(--color-primary); 69 }} 70 h2 {{ 71 - font-size: 2rem; 72 color: var(--color-secondary); 73 }} 74 h3 {{ 75 - font-size: 1.5rem; 76 color: var(--color-primary); 77 }} 78 h4 {{ 79 - font-size: 1.25rem; 80 color: var(--color-secondary); 81 }} 82 h5 {{ ··· 145 blockquote {{ 146 border-left: 4px solid var(--color-link); 147 padding-left: 1rem; 148 margin: 1rem 0; 149 font-style: italic; 150 }} ··· 182 .footnote-definition-label {{ 183 font-weight: 600; 184 margin-right: 0.5rem; 185 }} 186 187 /* Horizontal Rule */
··· 50 font-family: var(--font-body); 51 color: var(--color-foreground); 52 background-color: var(--color-background); 53 + max-width: 90ch; 54 margin: 0 auto; 55 padding: 2rem 1rem; 56 }} ··· 64 }} 65 66 h1 {{ 67 + font-size: 2rem; 68 color: var(--color-primary); 69 }} 70 h2 {{ 71 + font-size: 1.5rem; 72 color: var(--color-secondary); 73 }} 74 h3 {{ 75 + font-size: 1.25rem; 76 color: var(--color-primary); 77 }} 78 h4 {{ 79 + font-size: 1.2rem; 80 color: var(--color-secondary); 81 }} 82 h5 {{ ··· 145 blockquote {{ 146 border-left: 4px solid var(--color-link); 147 padding-left: 1rem; 148 + padding-right: 1rem; 149 margin: 1rem 0; 150 font-style: italic; 151 }} ··· 183 .footnote-definition-label {{ 184 font-weight: 600; 185 margin-right: 0.5rem; 186 + }} 187 + 188 + /* Images */ 189 + img {{ 190 + max-width: 100%; 191 + height: auto; 192 + display: block; 193 + margin: 1rem 0; 194 }} 195 196 /* Horizontal Rule */
+5 -1
crates/weaver-renderer/src/utils.rs
··· 72 73 /// Rough and ready check if a path is a local path. 74 /// Basically checks if the path is absolute and if so, is it accessible. 75 - /// Relative paths are assumed to be local 76 pub fn is_local_path(path: &str) -> bool { 77 let path = Path::new(path); 78 path.is_relative() || path.try_exists().unwrap_or(false) 79 }
··· 72 73 /// Rough and ready check if a path is a local path. 74 /// Basically checks if the path is absolute and if so, is it accessible. 75 + /// Relative paths are assumed to be local, but URL schemes are not 76 pub fn is_local_path(path: &str) -> bool { 77 + // Check for URL schemes (http, https, at, etc.) 78 + if path.contains("://") { 79 + return false; 80 + } 81 let path = Path::new(path); 82 path.is_relative() || path.try_exists().unwrap_or(false) 83 }
+2 -1
crates/weaver-server/src/components/css.rs
··· 1 #[allow(unused_imports)] 2 use crate::fetch; 3 #[allow(unused_imports)] 4 - use dioxus::{fullstack::get_server_url, CapturedError}; 5 use dioxus::{ 6 fullstack::{ 7 headers::ContentType, 8 http::header::CONTENT_TYPE, 9 response::{self, Response}, 10 }, 11 prelude::*, 12 }; 13 use jacquard::smol_str::SmolStr; 14 #[allow(unused_imports)]
··· 1 #[allow(unused_imports)] 2 use crate::fetch; 3 #[allow(unused_imports)] 4 use dioxus::{ 5 fullstack::{ 6 + get_server_url, 7 headers::ContentType, 8 http::header::CONTENT_TYPE, 9 response::{self, Response}, 10 }, 11 prelude::*, 12 + CapturedError, 13 }; 14 use jacquard::smol_str::SmolStr; 15 #[allow(unused_imports)]
+5 -6
crates/weaver-server/src/components/entry.rs
··· 1 #![allow(non_snake_case)] 2 3 - #[allow(unused_imports)] 4 - use crate::{blobcache::BlobCache, fetch}; 5 #[allow(unused_imports)] 6 use dioxus::{fullstack::extract::Extension, CapturedError}; 7 - use dioxus::{ 8 - fullstack::{get_server_url, reqwest}, 9 - prelude::*, 10 - }; 11 use jacquard::{prelude::IdentityResolver, smol_str::ToSmolStr}; 12 #[allow(unused_imports)] 13 use jacquard::{ ··· 78 } 79 80 /// Render some text as markdown. 81 pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element { 82 let content = &*props.content.read(); 83 let parser = markdown_weaver::Parser::new(&content.content);
··· 1 #![allow(non_snake_case)] 2 3 + #[cfg(feature = "server")] 4 + use crate::blobcache::BlobCache; 5 + use crate::fetch; 6 + use dioxus::prelude::*; 7 #[allow(unused_imports)] 8 use dioxus::{fullstack::extract::Extension, CapturedError}; 9 use jacquard::{prelude::IdentityResolver, smol_str::ToSmolStr}; 10 #[allow(unused_imports)] 11 use jacquard::{ ··· 76 } 77 78 /// Render some text as markdown. 79 + #[allow(unused)] 80 pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element { 81 let content = &*props.content.read(); 82 let parser = markdown_weaver::Parser::new(&content.content);
+2 -5
crates/weaver-server/src/components/identity.rs
··· 1 use crate::{fetch, Route}; 2 use dioxus::prelude::*; 3 - use jacquard::{ 4 - client::BasicClient, 5 - types::{ident::AtIdentifier, tid::Tid}, 6 - CowStr, 7 - }; 8 use weaver_api::sh_weaver::notebook::NotebookView; 9 #[component] 10 pub fn Repository(ident: AtIdentifier<'static>) -> Element { 11 rsx! {
··· 1 use crate::{fetch, Route}; 2 use dioxus::prelude::*; 3 + use jacquard::types::ident::AtIdentifier; 4 use weaver_api::sh_weaver::notebook::NotebookView; 5 + 6 #[component] 7 pub fn Repository(ident: AtIdentifier<'static>) -> Element { 8 rsx! {
+1
crates/weaver-server/src/main.rs
··· 6 use std::sync::Arc; 7 use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage}; 8 9 mod blobcache; 10 mod cache_impl; 11 /// Define a components module that contains all shared components for our app.
··· 6 use std::sync::Arc; 7 use views::{Home, Navbar, Notebook, NotebookIndex, NotebookPage}; 8 9 + #[cfg(feature = "server")] 10 mod blobcache; 11 mod cache_impl; 12 /// Define a components module that contains all shared components for our app.
+1 -1
crates/weaver-server/src/views/notebook.rs
··· 19 20 #[component] 21 pub fn NotebookIndex(ident: AtIdentifier<'static>, book_title: SmolStr) -> Element { 22 - let fetcher = use_context::<fetch::CachedFetcher>(); 23 rsx! {} 24 }
··· 19 20 #[component] 21 pub fn NotebookIndex(ident: AtIdentifier<'static>, book_title: SmolStr) -> Element { 22 + let _fetcher = use_context::<fetch::CachedFetcher>(); 23 rsx! {} 24 }
-5
crates/weaver-server/src/views/notebookpage.rs
··· 1 - use crate::Route; 2 use dioxus::prelude::*; 3 use jacquard::types::tid::Tid; 4 ··· 10 pub fn NotebookPage(id: Tid, children: Element) -> Element { 11 rsx! { 12 div { 13 - id: "blog", 14 15 - // Content 16 - h1 { "This is blog #{id}!" } 17 - p { "In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components." } 18 19 } 20 }
··· 1 use dioxus::prelude::*; 2 use jacquard::types::tid::Tid; 3 ··· 9 pub fn NotebookPage(id: Tid, children: Element) -> Element { 10 rsx! { 11 div { 12 13 14 } 15 }