diff --git a/crates/weaver-app/src/blobcache.rs b/crates/weaver-app/src/blobcache.rs index 79778d1..340a0cd 100644 --- a/crates/weaver-app/src/blobcache.rs +++ b/crates/weaver-app/src/blobcache.rs @@ -1,5 +1,8 @@ use crate::cache_impl; use crate::fetch::Fetcher; +#[cfg(all(feature = "fullstack-server", feature = "server"))] +use axum::Extension; +use dioxus::prelude::*; use dioxus::{CapturedError, Result}; use jacquard::{ IntoStatic, @@ -338,3 +341,128 @@ impl BlobCache { self.map.get(name).and_then(|cid| self.cache.get(&cid)) } } + +/// Build an image response with appropriate headers for immutable blobs. +#[cfg(all(feature = "fullstack-server", feature = "server"))] +fn build_image_response(bytes: jacquard::bytes::Bytes) -> axum::response::Response { + use axum::{ + http::header::{CACHE_CONTROL, CONTENT_TYPE}, + response::IntoResponse, + }; + use mime_sniffer::MimeTypeSniffer; + + let mime = bytes.sniff_mime_type().unwrap_or("image/jpg").to_string(); + ( + [ + (CONTENT_TYPE, mime), + ( + CACHE_CONTROL, + "public, max-age=31536000, immutable".to_string(), + ), + ], + bytes, + ) + .into_response() +} + +/// Return a 404 response for missing images. +#[cfg(all(feature = "fullstack-server", feature = "server"))] +fn image_not_found() -> axum::response::Response { + use axum::{http::StatusCode, response::IntoResponse}; + (StatusCode::NOT_FOUND, "Image not found").into_response() +} + +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/{notebook}/image/{name}", blob_cache: Extension>)] +pub async fn image_named(notebook: SmolStr, name: SmolStr) -> Result { + if let Some(bytes) = blob_cache.get_named(&name) { + return Ok(build_image_response(bytes)); + } + + // Try to resolve from notebook + match blob_cache.resolve_from_notebook(¬ebook, &name).await { + Ok(bytes) => Ok(build_image_response(bytes)), + Err(_) => Ok(image_not_found()), + } +} + +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/{_notebook}/blob/{cid}", blob_cache: Extension>)] +pub async fn blob(_notebook: SmolStr, cid: SmolStr) -> Result { + match Cid::new_owned(cid.as_bytes()) { + Ok(cid) => { + if let Some(bytes) = blob_cache.get_cid(&cid) { + Ok(build_image_response(bytes)) + } else { + Ok(image_not_found()) + } + } + Err(_) => Ok(image_not_found()), + } +} + +// Route: /image/{notebook}/{name} - notebook entry image by name +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/image/{notebook}/{name}", blob_cache: Extension>)] +pub async fn image_notebook(notebook: SmolStr, name: SmolStr) -> Result { + // Try name-based lookup first (backwards compat with cached entries) + if let Some(bytes) = blob_cache.get_named(&name) { + return Ok(build_image_response(bytes)); + } + + // Try to resolve from notebook + match blob_cache.resolve_from_notebook(¬ebook, &name).await { + Ok(bytes) => Ok(build_image_response(bytes)), + Err(_) => Ok(image_not_found()), + } +} + +// Route: /image/{ident}/draft/{blob_rkey} - draft image (unpublished) +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/image/{ident}/draft/{blob_rkey}", blob_cache: Extension>)] +pub async fn image_draft(ident: SmolStr, blob_rkey: SmolStr) -> Result { + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { + return Ok(image_not_found()); + }; + + match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { + Ok(bytes) => Ok(build_image_response(bytes)), + Err(_) => Ok(image_not_found()), + } +} + +// Route: /image/{ident}/draft/{blob_rkey}/{name} - draft image with name (name is decorative) +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/image/{ident}/draft/{blob_rkey}/{_name}", blob_cache: Extension>)] +pub async fn image_draft_named( + ident: SmolStr, + blob_rkey: SmolStr, + _name: SmolStr, +) -> Result { + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { + return Ok(image_not_found()); + }; + + match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await { + Ok(bytes) => Ok(build_image_response(bytes)), + Err(_) => Ok(image_not_found()), + } +} + +// Route: /image/{ident}/{rkey}/{name} - published entry image +#[cfg(all(feature = "fullstack-server", feature = "server"))] +#[get("/image/{ident}/{rkey}/{name}", blob_cache: Extension>)] +pub async fn image_entry( + ident: SmolStr, + rkey: SmolStr, + name: SmolStr, +) -> Result { + let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else { + return Ok(image_not_found()); + }; + + match blob_cache.resolve_from_entry(&at_ident, &rkey, &name).await { + Ok(bytes) => Ok(build_image_response(bytes)), + Err(_) => Ok(image_not_found()), + } +} diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index 4311d45..c19ac42 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -141,6 +141,38 @@ pub fn MarkdownEditor( tags.push(tag_str).ok(); } } + + // Restore existing embeds from the entry + if let Some(ref embeds) = loaded.entry.embeds { + let embeds_map = doc.get_map("embeds"); + + // Restore images + if let Some(ref images) = embeds.images { + let images_list = embeds_map + .get_or_create_container("images", loro::LoroList::new()) + .expect("images list"); + for image in &images.images { + // Serialize image to JSON and add to list + // No publishedBlobUri since these are already published + let json = serde_json::to_value(image) + .expect("Image serializes"); + images_list.push(json).ok(); + } + } + + // Restore record embeds + if let Some(ref records) = embeds.records { + let records_list = embeds_map + .get_or_create_container("records", loro::LoroList::new()) + .expect("records list"); + for record in &records.records { + let json = serde_json::to_value(record) + .expect("RecordEmbed serializes"); + records_list.push(json).ok(); + } + } + } + doc.commit(); return LoadResult::Loaded(LoadedDocState { @@ -233,13 +265,40 @@ fn MarkdownEditorInner( let auth_state = use_context::>(); let mut document = use_hook(|| { - let doc = EditorDocument::from_loaded_state(loaded_state.clone()); + let mut doc = EditorDocument::from_loaded_state(loaded_state.clone()); + + // Seed collected_refs with existing record embeds so they get fetched/rendered + let record_embeds = doc.record_embeds(); + if !record_embeds.is_empty() { + let refs: Vec = record_embeds + .into_iter() + .filter_map(|embed| { + embed.name.map(|name| weaver_common::ExtractedRef::AtEmbed { + uri: name.to_string(), + alt_text: None, + }) + }) + .collect(); + doc.set_collected_refs(refs); + } + storage::save_to_storage(&doc, &draft_key).ok(); doc }); let editor_id = "markdown-editor"; let mut render_cache = use_signal(|| render::RenderCache::default()); - let mut image_resolver = use_signal(EditorImageResolver::default); + + // Populate resolver from existing images if editing a published entry + let mut image_resolver: Signal = use_signal(|| { + let images = document.images(); + if let (false, Some(ref r)) = (images.is_empty(), document.entry_ref()) { + let ident = r.uri.authority().clone().into_static(); + let entry_rkey = r.uri.rkey().map(|rk| rk.0.clone().into_static()); + EditorImageResolver::from_images(&images, ident, entry_rkey) + } else { + EditorImageResolver::default() + } + }); let resolved_content = use_signal(weaver_common::ResolvedContent::default); let doc_for_memo = document.clone(); @@ -1095,14 +1154,29 @@ fn MarkdownEditorInner( } else { uploaded.alt.clone() }; - let markdown = format!("![{}](/image/{})", alt_text, name); + + // Check if authenticated and get DID for draft path + let auth = auth_state.read(); + let did_for_path = auth.did.clone(); + let is_authenticated = auth.is_authenticated(); + drop(auth); + + // Pre-generate TID for the blob rkey (used in draft path and upload) + let blob_tid = jacquard::types::tid::Ticker::new().next(None); + + // Build markdown with proper draft path if authenticated + let markdown = if let Some(ref did) = did_for_path { + format!("![{}](/image/{}/draft/{}/{})", alt_text, did, blob_tid.as_str(), name) + } else { + // Fallback for unauthenticated - simple path (won't be publishable anyway) + format!("![{}](/image/{})", alt_text, name) + }; let pos = doc.cursor.read().offset; let _ = doc.insert_tracked(pos, &markdown); doc.cursor.write().offset = pos + markdown.chars().count(); // Upload to PDS in background if authenticated - let is_authenticated = auth_state.read().is_authenticated(); if is_authenticated { let fetcher = fetcher.clone(); let name_for_upload = name.clone(); @@ -1116,8 +1190,12 @@ fn MarkdownEditorInner( // Clone data for cache pre-warming let data_for_cache = data.clone(); + // Use pre-generated TID as rkey for the blob record + let rkey = jacquard::types::recordkey::RecordKey::any(blob_tid.as_str()) + .expect("TID is valid record key"); + // Upload blob and create temporary PublishedBlob record - match client.publish_blob(data, &name_for_upload, None).await { + match client.publish_blob(data, &name_for_upload, Some(rkey)).await { Ok((strong_ref, published_blob)) => { // Get DID from fetcher let did = match fetcher.current_did().await { diff --git a/crates/weaver-app/src/components/editor/document.rs b/crates/weaver-app/src/components/editor/document.rs index 60f76d4..7d54eb4 100644 --- a/crates/weaver-app/src/components/editor/document.rs +++ b/crates/weaver-app/src/components/editor/document.rs @@ -26,6 +26,7 @@ use jacquard::from_json_value; use jacquard::types::string::AtUri; use weaver_api::com_atproto::repo::strong_ref::StrongRef; use weaver_api::sh_weaver::embed::images::Image; +use weaver_api::sh_weaver::embed::records::RecordEmbed; use weaver_api::sh_weaver::notebook::entry::Entry; /// Helper for working with editor images. @@ -614,6 +615,37 @@ impl EditorDocument { } } + // --- Record embed methods --- + + /// Get the records LoroList from embeds, creating it if needed. + fn get_records_list(&self) -> LoroList { + self.embeds + .get_or_create_container("records", LoroList::new()) + .unwrap() + } + + /// Get all record embeds as a Vec. + pub fn record_embeds(&self) -> Vec> { + let records_list = self.get_records_list(); + let mut result = Vec::new(); + + for i in 0..records_list.len() { + if let Some(record_embed) = self.loro_value_to_record_embed(&records_list, i) { + result.push(record_embed); + } + } + + result + } + + /// Convert a LoroValue at the given index to a RecordEmbed. + fn loro_value_to_record_embed(&self, list: &LoroList, index: usize) -> Option> { + let value = list.get(index)?; + let loro_value = value.as_value()?; + let json = loro_value.to_json_value(); + from_json_value::(json).ok().map(|r| r.into_static()) + } + /// Insert text into content and record edit info for incremental rendering. pub fn insert_tracked(&mut self, pos: usize, text: &str) -> LoroResult<()> { let in_block_syntax_zone = self.is_in_block_syntax_zone(pos); diff --git a/crates/weaver-app/src/components/editor/publish.rs b/crates/weaver-app/src/components/editor/publish.rs index 71209e4..c4652ba 100644 --- a/crates/weaver-app/src/components/editor/publish.rs +++ b/crates/weaver-app/src/components/editor/publish.rs @@ -3,6 +3,7 @@ //! Handles creating/updating/loading AT Protocol notebook entries. use dioxus::prelude::*; +use jacquard::cowstr::ToCowStr; use jacquard::types::collection::Collection; use jacquard::types::ident::AtIdentifier; use jacquard::types::recordkey::RecordKey; @@ -171,7 +172,13 @@ pub async fn publish_entry( for uri in at_embed_uris { match fetcher.confirm_record_ref(&uri).await { Ok(strong_ref) => { - record_embeds.push(RecordEmbed::new().record(strong_ref).build()); + // Store original URI in name field for lookup when authority differs (handle vs DID) + record_embeds.push( + RecordEmbed::new() + .name(uri.to_cowstr().into_static()) + .record(strong_ref) + .build(), + ); } Err(e) => { tracing::warn!("Failed to resolve embed {}: {}", uri, e); @@ -180,6 +187,11 @@ pub async fn publish_entry( } // Build embeds if we have images or records + tracing::debug!( + "[publish_entry] Building embeds: {} images, {} record embeds", + editor_images.len(), + record_embeds.len() + ); let entry_embeds = if editor_images.is_empty() && record_embeds.is_empty() { None } else { @@ -428,7 +440,10 @@ pub fn PublishButton(props: PublishButtonProps) -> Element { let mut show_dialog = use_signal(|| false); let mut notebook_title = use_signal(|| { - props.target_notebook.clone().unwrap_or_else(|| String::from("Default")) + props + .target_notebook + .clone() + .unwrap_or_else(|| String::from("Default")) }); let mut use_notebook = use_signal(|| props.target_notebook.is_some()); let mut is_publishing = use_signal(|| false); diff --git a/crates/weaver-app/src/components/editor/render.rs b/crates/weaver-app/src/components/editor/render.rs index fb97d3c..d5fce66 100644 --- a/crates/weaver-app/src/components/editor/render.rs +++ b/crates/weaver-app/src/components/editor/render.rs @@ -40,6 +40,8 @@ pub struct CachedParagraph { pub offset_map: Vec, /// Syntax spans for conditional visibility pub syntax_spans: Vec, + /// Collected refs (wikilinks, AT embeds) from this paragraph + pub collected_refs: Vec, } /// Check if an edit affects paragraph boundaries. @@ -148,6 +150,7 @@ pub fn render_paragraphs_incremental( html: empty_html, offset_map: vec![], syntax_spans: vec![], + collected_refs: vec![], }], next_node_id: 1, next_syn_id: 0, @@ -294,7 +297,7 @@ pub fn render_paragraphs_incremental( let cached_match = cache.and_then(|c| c.paragraphs.iter().find(|p| p.source_hash == source_hash)); - let (html, offset_map, syntax_spans) = if let Some(cached) = cached_match { + let (html, offset_map, syntax_spans, para_refs) = if let Some(cached) = cached_match { // Reuse cached HTML, offset map, and syntax spans (adjusted for position) let char_delta = char_range.start as isize - cached.char_range.start as isize; let byte_delta = byte_range.start as isize - cached.byte_range.start as isize; @@ -314,7 +317,10 @@ pub fn render_paragraphs_incremental( span.adjust_positions(char_delta); } - (cached.html.clone(), adjusted_map, adjusted_syntax) + // Include cached refs in all_refs + all_refs.extend(cached.collected_refs.clone()); + + (cached.html.clone(), adjusted_map, adjusted_syntax, cached.collected_refs.clone()) } else { // Fresh render needed - create detached LoroDoc for this paragraph let para_doc = loro::LoroDoc::new(); @@ -348,7 +354,7 @@ pub fn render_paragraphs_incremental( writer = writer.with_entry_index(idx); } - let (mut offset_map, mut syntax_spans) = match writer.run() { + let (mut offset_map, mut syntax_spans, para_refs) = match writer.run() { Ok(result) => { // Update node ID offset let max_node_id = result @@ -377,16 +383,17 @@ pub fn render_paragraphs_incremental( syn_id_offset = max_syn_id + 1; // Collect refs from this paragraph - all_refs.extend(result.collected_refs); + let para_refs = result.collected_refs; + all_refs.extend(para_refs.clone()); - (result.offset_maps, result.syntax_spans) + (result.offset_maps, result.syntax_spans, para_refs) } - Err(_) => (Vec::new(), Vec::new()), + Err(_) => (Vec::new(), Vec::new(), Vec::new()), }; // Offsets are already document-absolute since we pass char_range.start/byte_range.start // to the writer constructor - (output, offset_map, syntax_spans) + (output, offset_map, syntax_spans, para_refs) }; // Store in cache @@ -397,6 +404,7 @@ pub fn render_paragraphs_incremental( html: html.clone(), offset_map: offset_map.clone(), syntax_spans: syntax_spans.clone(), + collected_refs: para_refs, }); paragraphs.push(ParagraphRender { diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap index dae2340..93cf634 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__blockquote.snap @@ -11,8 +11,8 @@ expression: result html: "
\n

> This is a quote\n

\n
\n" offset_map: - byte_range: - - 43 - - 43 + - 2 + - 2 char_range: - 0 - 0 @@ -21,8 +21,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 41 - - 42 + - 0 + - 1 char_range: - 0 - 1 @@ -31,8 +31,8 @@ expression: result child_index: ~ utf16_len: 1 - byte_range: - - 42 - - 43 + - 1 + - 2 char_range: - 1 - 2 @@ -41,8 +41,8 @@ expression: result child_index: ~ utf16_len: 1 - byte_range: - - 43 - - 58 + - 2 + - 17 char_range: - 2 - 17 @@ -51,8 +51,8 @@ expression: result child_index: ~ utf16_len: 15 - byte_range: - - 58 - - 59 + - 17 + - 18 char_range: - 17 - 18 @@ -89,8 +89,8 @@ expression: result html: "

With multiple lines

\n" offset_map: - byte_range: - - 22 - - 22 + - 0 + - 0 char_range: - 22 - 22 @@ -99,8 +99,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 22 - - 41 + - 0 + - 19 char_range: - 22 - 41 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap index 69acb81..6233f74 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__code_block_fenced.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 24 - html: "```rust\n
fn main() {}\n
```" + html: "```rust\n
fn main() {}\n
```" offset_map: - byte_range: - 8 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap index cc09cec..d5b69bd 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__gap_between_blocks.snap @@ -60,8 +60,8 @@ expression: result html: "

Paragraph below

\n" offset_map: - byte_range: - - 11 - - 11 + - 0 + - 0 char_range: - 11 - 11 @@ -70,8 +70,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 11 - - 26 + - 0 + - 15 char_range: - 11 - 26 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap index bcc0713..4740ffb 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__heading_levels.snap @@ -57,11 +57,11 @@ expression: result char_range: - 6 - 12 - html: "

## H2\n

\n" + html: "

## H2\n

\n" offset_map: - byte_range: - - 6 - - 6 + - 0 + - 0 char_range: - 6 - 6 @@ -70,8 +70,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 6 - - 9 + - 0 + - 3 char_range: - 6 - 9 @@ -80,8 +80,8 @@ expression: result child_index: ~ utf16_len: 3 - byte_range: - - 9 - - 11 + - 3 + - 5 char_range: - 9 - 11 @@ -90,8 +90,8 @@ expression: result child_index: ~ utf16_len: 2 - byte_range: - - 11 - - 12 + - 5 + - 6 char_range: - 11 - 12 @@ -106,11 +106,11 @@ expression: result char_range: - 13 - 20 - html: "

### H3\n

\n" + html: "

### H3\n

\n" offset_map: - byte_range: - - 13 - - 13 + - 0 + - 0 char_range: - 13 - 13 @@ -119,8 +119,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 13 - - 17 + - 0 + - 4 char_range: - 13 - 17 @@ -129,8 +129,8 @@ expression: result child_index: ~ utf16_len: 4 - byte_range: - - 17 - - 19 + - 4 + - 6 char_range: - 17 - 19 @@ -139,8 +139,8 @@ expression: result child_index: ~ utf16_len: 2 - byte_range: - - 19 - - 20 + - 6 + - 7 char_range: - 19 - 20 @@ -155,11 +155,11 @@ expression: result char_range: - 21 - 28 - html: "

#### H4

\n" + html: "

#### H4

\n" offset_map: - byte_range: - - 21 - - 21 + - 0 + - 0 char_range: - 21 - 21 @@ -168,8 +168,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 21 - - 26 + - 0 + - 5 char_range: - 21 - 26 @@ -178,8 +178,8 @@ expression: result child_index: ~ utf16_len: 5 - byte_range: - - 26 - - 28 + - 5 + - 7 char_range: - 26 - 28 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap index 18dca3e..8758feb 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__inline_code.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 16 - html: "

Some `code` here

\n" + html: "

Some `code` here

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap index 22f97a4..3915940 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_blank_lines.snap @@ -69,8 +69,8 @@ expression: result html: "

Second

\n" offset_map: - byte_range: - - 9 - - 9 + - 0 + - 0 char_range: - 9 - 9 @@ -79,8 +79,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 9 - - 15 + - 0 + - 6 char_range: - 9 - 15 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap index b367c1c..9e845fa 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__multiple_inline_formats.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 32 - html: "

**Bold** and *italic* and `code`

\n" + html: "

**Bold** and *italic* and `code`

\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap index 184614a..43bce36 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__nested_list.snap @@ -27,11 +27,11 @@ expression: result char_range: - 11 - 33 - html: "
    \n
  • - Child 1\n \n
      \n
    • - Child 2\n
    • \n
    \n
  • \n
\n" + html: "
    \n
  • - Child 1\n \n
      \n
    • - Child 2\n
    • \n
    \n
  • \n
\n" offset_map: - byte_range: - - 11 - - 13 + - 0 + - 2 char_range: - 11 - 13 @@ -40,8 +40,8 @@ expression: result child_index: ~ utf16_len: 2 - byte_range: - - 13 - - 20 + - 2 + - 9 char_range: - 13 - 20 @@ -50,8 +50,8 @@ expression: result child_index: ~ utf16_len: 7 - byte_range: - - 20 - - 23 + - 9 + - 12 char_range: - 20 - 23 @@ -60,8 +60,8 @@ expression: result child_index: ~ utf16_len: 3 - byte_range: - - 23 - - 25 + - 12 + - 14 char_range: - 23 - 25 @@ -70,8 +70,8 @@ expression: result child_index: ~ utf16_len: 2 - byte_range: - - 25 - - 32 + - 14 + - 21 char_range: - 25 - 32 @@ -80,8 +80,8 @@ expression: result child_index: ~ utf16_len: 7 - byte_range: - - 32 - - 33 + - 21 + - 22 char_range: - 32 - 33 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap index 7b301c1..5436f6e 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__ordered_list.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 27 - html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" + html: "
    \n
  1. 1. First\n
  2. \n
  3. 2. Second\n
  4. \n
  5. 3. Third
  6. \n
\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap index bf13ea0..aba0ec2 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__three_paragraphs.snap @@ -50,8 +50,8 @@ expression: result html: "

Two.\n

\n" offset_map: - byte_range: - - 6 - - 6 + - 0 + - 0 char_range: - 6 - 6 @@ -60,8 +60,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 6 - - 10 + - 0 + - 4 char_range: - 6 - 10 @@ -70,8 +70,8 @@ expression: result child_index: ~ utf16_len: 4 - byte_range: - - 10 - - 11 + - 4 + - 5 char_range: - 10 - 11 @@ -89,8 +89,8 @@ expression: result html: "

Three.

\n" offset_map: - byte_range: - - 12 - - 12 + - 0 + - 0 char_range: - 12 - 12 @@ -99,8 +99,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 12 - - 18 + - 0 + - 6 char_range: - 12 - 18 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap index e36817b..400962c 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__two_paragraphs.snap @@ -50,8 +50,8 @@ expression: result html: "

Second paragraph.

\n" offset_map: - byte_range: - - 18 - - 18 + - 0 + - 0 char_range: - 18 - 18 @@ -60,8 +60,8 @@ expression: result child_index: 0 utf16_len: 0 - byte_range: - - 18 - - 35 + - 0 + - 17 char_range: - 18 - 35 diff --git a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap index d0eae33..e8b813a 100644 --- a/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap +++ b/crates/weaver-app/src/components/editor/snapshots/weaver_app__components__editor__tests__unordered_list.snap @@ -8,7 +8,7 @@ expression: result char_range: - 0 - 26 - html: "
    \n
  • - Item 1\n
  • \n
  • - Item 2\n
  • \n
  • - Item 3
  • \n
\n" + html: "
    \n
  • - Item 1\n
  • \n
  • - Item 2\n
  • \n
  • - Item 3
  • \n
\n" offset_map: - byte_range: - 0 diff --git a/crates/weaver-app/src/components/editor/tests.rs b/crates/weaver-app/src/components/editor/tests.rs index 19579b2..86dcc2f 100644 --- a/crates/weaver-app/src/components/editor/tests.rs +++ b/crates/weaver-app/src/components/editor/tests.rs @@ -728,8 +728,8 @@ fn test_blockquote_needs_space_or_newline() { html: "
\n

>quote

\n
\n" offset_map: - byte_range: - - 7 - - 7 + - 1 + - 1 char_range: - 0 - 0 @@ -738,8 +738,8 @@ fn test_blockquote_needs_space_or_newline() { child_index: 0 utf16_len: 0 - byte_range: - - 6 - - 7 + - 0 + - 1 char_range: - 0 - 1 @@ -748,8 +748,8 @@ fn test_blockquote_needs_space_or_newline() { child_index: ~ utf16_len: 1 - byte_range: - - 7 - - 12 + - 1 + - 6 char_range: - 1 - 6 diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index c01bb65..d876d5f 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -206,10 +206,8 @@ impl EditorImageResolver { blob_rkey: Rkey<'static>, ident: AtIdentifier<'static>, ) { - self.images.insert( - name.to_string(), - ResolvedImage::Draft { blob_rkey, ident }, - ); + self.images + .insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident }); } /// Add an already-uploaded draft image. @@ -314,7 +312,13 @@ impl ImageResolver for &EditorImageResolver { /// /// This writer processes offset-iter events to detect gaps (consumed formatting) /// and emits them as styled spans for visibility in the editor. -pub struct EditorWriter<'a, I: Iterator, Range)>, W: StrWrite, E = (), R = ()> { +pub struct EditorWriter< + 'a, + I: Iterator, Range)>, + W: StrWrite, + E = (), + R = (), +> { source: &'a str, source_text: &'a LoroText, events: I, @@ -387,12 +391,12 @@ enum TableState { } impl< - 'a, - I: Iterator, Range)>, - W: StrWrite, - E: EmbedContentProvider, - R: ImageResolver, - > EditorWriter<'a, I, W, E, R> + 'a, + I: Iterator, Range)>, + W: StrWrite, + E: EmbedContentProvider, + R: ImageResolver, +> EditorWriter<'a, I, W, E, R> { pub fn new(source: &'a str, source_text: &'a LoroText, events: I, writer: W) -> Self { Self::new_with_node_offset(source, source_text, events, writer, 0) @@ -1329,8 +1333,7 @@ impl< write!( &mut self.writer, "{}", - content_char_start, - mathml + content_char_start, mathml )?; } weaver_renderer::math::MathResult::Error { html, .. } => { @@ -1423,8 +1426,7 @@ impl< write!( &mut self.writer, "{}", - content_char_start, - mathml + content_char_start, mathml )?; } weaver_renderer::math::MathResult::Error { html, .. } => { @@ -1740,7 +1742,6 @@ impl< } // Emit the opening tag - tracing::debug!(?tag, "start_tag"); match tag { Tag::HtmlBlock => Ok(()), Tag::Paragraph => { @@ -2612,7 +2613,11 @@ impl< Err(_) => { // Fallback to plain code block if let Some(ref nid) = node_id { - write!(&mut self.writer, "
, Range)>,
-        W: StrWrite,
-        E: EmbedContentProvider,
-        R: ImageResolver,
-    > EditorWriter<'a, I, W, E, R>
+    'a,
+    I: Iterator, Range)>,
+    W: StrWrite,
+    E: EmbedContentProvider,
+    R: ImageResolver,
+> EditorWriter<'a, I, W, E, R>
 {
     fn write_embed(
         &mut self,
@@ -2891,10 +2897,7 @@ impl<
             formatted_range: Some(formatted_range.clone()),
         });
 
-        self.record_mapping(
-            range.start + 3..range.end - 2,
-            url_char_start..url_char_end,
-        );
+        self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end);
 
         // 3. Emit closing ]] syntax span
         if raw_text.ends_with("]]") {
@@ -2919,7 +2922,14 @@ impl<
 
         // Collect AT URI for later resolution
         if url.starts_with("at://") || url.starts_with("did:") {
-            self.ref_collector.add_at_embed(url, if title.is_empty() { None } else { Some(title.as_ref()) });
+            self.ref_collector.add_at_embed(
+                url,
+                if title.is_empty() {
+                    None
+                } else {
+                    Some(title.as_ref())
+                },
+            );
         }
 
         // 4. Emit the actual embed content
diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs
index 76afe5a..9020e9c 100644
--- a/crates/weaver-app/src/components/entry.rs
+++ b/crates/weaver-app/src/components/entry.rs
@@ -835,7 +835,9 @@ pub struct EntryMarkdownProps {
 
 /// Render some text as markdown.
 pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element {
-    let processed = crate::data::use_rendered_markdown(props.content, props.ident);
+    let (_res, processed) = crate::data::use_rendered_markdown(props.content, props.ident);
+    #[cfg(feature = "fullstack-server")]
+    _res?;
 
     match &*processed.read() {
         Some(html_buf) => rsx! {
@@ -866,7 +868,9 @@ fn EntryMarkdownDirect(
     // Use feature-gated hook for SSR support
     let content = use_signal(|| content);
     let ident = use_signal(|| ident);
-    let processed = crate::data::use_rendered_markdown(content.into(), ident.into());
+    let (_res, processed) = crate::data::use_rendered_markdown(content.into(), ident.into());
+    #[cfg(feature = "fullstack-server")]
+    _res?;
 
     match &*processed.read() {
         Some(html_buf) => rsx! {
diff --git a/crates/weaver-app/src/components/identity.rs b/crates/weaver-app/src/components/identity.rs
index 7dda4f4..37a0ea9 100644
--- a/crates/weaver-app/src/components/identity.rs
+++ b/crates/weaver-app/src/components/identity.rs
@@ -46,10 +46,6 @@ const NOTEBOOK_CARD_CSS: Asset = asset!("/assets/styling/notebook-card.css");
 
 #[component]
 pub fn Repository(ident: ReadSignal>) -> Element {
-    tracing::debug!("Repository component rendering for ident: {:?}", ident());
-    // Fetch notebooks for this specific DID with SSR support;
-    tracing::debug!("Repository component context set up");
-
     rsx! {
         div {
             Outlet:: {}
@@ -59,14 +55,9 @@ pub fn Repository(ident: ReadSignal>) -> Element {
 
 #[component]
 pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
-    tracing::debug!(
-        "RepositoryIndex component rendering for ident: {:?}",
-        ident()
-    );
     use crate::components::ProfileDisplay;
     let (notebooks_result, notebooks) = data::use_notebooks_for_did(ident);
     let (profile_result, profile) = crate::data::use_profile_data(ident);
-    tracing::debug!("RepositoryIndex got profile and notebooks");
 
     #[cfg(feature = "fullstack-server")]
     notebooks_result?;
@@ -81,20 +72,30 @@ pub fn RepositoryIndex(ident: ReadSignal>) -> Element {
 
             let (display_name, handle, bio) = match &profile_view.inner {
                 ProfileDataViewInner::ProfileView(p) => (
-                    p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(),
+                    p.display_name
+                        .as_ref()
+                        .map(|n| n.as_ref().to_string())
+                        .unwrap_or_default(),
                     p.handle.as_ref().to_string(),
-                    p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(),
+                    p.description
+                        .as_ref()
+                        .map(|d| d.as_ref().to_string())
+                        .unwrap_or_default(),
                 ),
                 ProfileDataViewInner::ProfileViewDetailed(p) => (
-                    p.display_name.as_ref().map(|n| n.as_ref().to_string()).unwrap_or_default(),
-                    p.handle.as_ref().to_string(),
-                    p.description.as_ref().map(|d| d.as_ref().to_string()).unwrap_or_default(),
-                ),
-                ProfileDataViewInner::TangledProfileView(p) => (
-                    String::new(),
+                    p.display_name
+                        .as_ref()
+                        .map(|n| n.as_ref().to_string())
+                        .unwrap_or_default(),
                     p.handle.as_ref().to_string(),
-                    String::new(),
+                    p.description
+                        .as_ref()
+                        .map(|d| d.as_ref().to_string())
+                        .unwrap_or_default(),
                 ),
+                ProfileDataViewInner::TangledProfileView(p) => {
+                    (String::new(), p.handle.as_ref().to_string(), String::new())
+                }
                 _ => (String::new(), "unknown".to_string(), String::new()),
             };
 
@@ -174,7 +175,7 @@ pub fn NotebookCard(
     notebook: NotebookView<'static>,
     entry_refs: Vec>,
 ) -> Element {
-    use jacquard::{from_data, IntoStatic};
+    use jacquard::{IntoStatic, from_data};
     use weaver_api::sh_weaver::notebook::book::Book;
 
     let fetcher = use_context::();
diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs
index e0e5763..4311876 100644
--- a/crates/weaver-app/src/data.rs
+++ b/crates/weaver-app/src/data.rs
@@ -13,7 +13,7 @@ use dioxus::{CapturedError, fullstack::extract::Extension};
 use jacquard::{
     IntoStatic,
     identity::resolver::IdentityError,
-    types::{did::Did, string::Handle},
+    types::{aturi::AtUri, did::Did, string::Handle},
 };
 #[allow(unused_imports)]
 use jacquard::{
@@ -26,6 +26,7 @@ use std::sync::Arc;
 use weaver_api::com_atproto::repo::strong_ref::StrongRef;
 use weaver_api::sh_weaver::actor::ProfileDataView;
 use weaver_api::sh_weaver::notebook::{BookEntryView, EntryView, NotebookView, entry::Entry};
+use weaver_common::ResolvedContent;
 // ============================================================================
 // Wrapper Hooks (feature-gated)
 // ============================================================================
@@ -45,9 +46,7 @@ pub fn use_entry_data(
     let res = use_server_future(use_reactive!(|(ident, book_title, title)| {
         let fetcher = fetcher.clone();
         async move {
-            let fetch_result = fetcher
-                .get_entry(ident(), book_title(), title())
-                .await;
+            let fetch_result = fetcher.get_entry(ident(), book_title(), title()).await;
 
             match fetch_result {
                 Ok(Some(entry)) => {
@@ -387,26 +386,35 @@ pub fn use_handle(
 pub fn use_rendered_markdown(
     content: ReadSignal>,
     ident: ReadSignal>,
-) -> Memo> {
+) -> (
+    Result>, RenderError>,
+    Memo>,
+) {
     let fetcher = use_context::();
+    let fetcher = fetcher.clone();
     let res = use_server_future(use_reactive!(|(content, ident)| {
-        let client = fetcher.get_client();
+        let fetcher = fetcher.clone();
         async move {
+            let entry = content();
             let did = match ident.read().clone() {
                 AtIdentifier::Did(d) => d,
-                AtIdentifier::Handle(h) => client.resolve_handle(&h).await.ok()?,
+                AtIdentifier::Handle(h) => fetcher.get_client().resolve_handle(&h).await.ok()?,
             };
-            Some(render_markdown_impl(content(), did).await)
+
+            let resolved_content = prefetch_embeds(&entry, &fetcher).await;
+
+            Some(render_markdown_impl(entry, did, resolved_content).await)
         }
     }));
-    use_memo(use_reactive!(|res| {
-        let res = res.ok()?;
+    let memo = use_memo(use_reactive!(|res| {
+        let res = res.as_ref().ok()?;
         if let Some(Some(value)) = &*res.read() {
             Some(value.clone())
         } else {
             None
         }
-    }))
+    }));
+    (res, memo)
 }
 
 /// Hook to render markdown client-side only (no SSR).
@@ -414,29 +422,97 @@ pub fn use_rendered_markdown(
 pub fn use_rendered_markdown(
     content: ReadSignal>,
     ident: ReadSignal>,
-) -> Memo> {
+) -> (Resource>, Memo>) {
     let fetcher = use_context::();
+    let fetcher = fetcher.clone();
     let res = use_resource(move || {
-        let client = fetcher.get_client();
+        let fetcher = fetcher.clone();
         async move {
+            let entry = content();
             let did = match ident() {
                 AtIdentifier::Did(d) => d,
-                AtIdentifier::Handle(h) => client.resolve_handle(&h).await.ok()?,
+                AtIdentifier::Handle(h) => fetcher.get_client().resolve_handle(&h).await.ok()?,
             };
-            Some(render_markdown_impl(content(), did).await)
+
+            let resolved_content = prefetch_embeds(&entry, &fetcher).await;
+
+            Some(render_markdown_impl(entry, did, resolved_content).await)
         }
     });
-    use_memo(move || {
+    let memo = use_memo(move || {
         if let Some(Some(value)) = &*res.read() {
             Some(value.clone())
         } else {
             None
         }
-    })
+    });
+    (res, memo)
+}
+
+/// Extract AT URIs for embeds from stored records or by parsing markdown.
+///
+/// Tries stored `embeds.records` first, falls back to parsing markdown content.
+fn extract_embed_uris(entry: &Entry<'_>) -> Vec> {
+    use jacquard::IntoStatic;
+
+    // Try stored records first
+    if let Some(ref embeds) = entry.embeds {
+        if let Some(ref records) = embeds.records {
+            let stored_uris: Vec<_> = records
+                .records
+                .iter()
+                .map(|r| r.record.uri.clone().into_static())
+                .collect();
+            if !stored_uris.is_empty() {
+                return stored_uris;
+            }
+        }
+    }
+
+    // Fall back to parsing markdown for at:// URIs
+    use regex_lite::Regex;
+    use std::sync::LazyLock;
+
+    static AT_URI_REGEX: LazyLock =
+        LazyLock::new(|| Regex::new(r"at://[^\s\)\]]+").unwrap());
+
+    let uris: Vec<_> = AT_URI_REGEX
+        .find_iter(&entry.content)
+        .filter_map(|m| AtUri::new(m.as_str()).ok().map(|u| u.into_static()))
+        .collect();
+    uris
+}
+
+/// Pre-fetch embed content for all AT URIs in an entry.
+async fn prefetch_embeds(
+    entry: &Entry<'static>,
+    fetcher: &crate::fetch::Fetcher,
+) -> weaver_common::ResolvedContent {
+    use weaver_renderer::atproto::fetch_and_render;
+
+    let mut resolved = weaver_common::ResolvedContent::new();
+    let uris = extract_embed_uris(entry);
+
+    for uri in uris {
+        match fetch_and_render(&uri, fetcher).await {
+            Ok(html) => {
+                resolved.add_embed(uri, html, None);
+            }
+            Err(e) => {
+                tracing::warn!("[prefetch_embeds] Failed to fetch {}: {}", uri, e);
+            }
+        }
+    }
+
+    resolved
 }
 
 /// Internal implementation of markdown rendering.
-async fn render_markdown_impl(content: Entry<'static>, did: Did<'static>) -> String {
+async fn render_markdown_impl(
+    content: Entry<'static>,
+    did: Did<'static>,
+    resolved_content: weaver_common::ResolvedContent,
+) -> String {
     use n0_future::stream::StreamExt;
     use weaver_renderer::{
         ContextIterator, NotebookProcessor,
@@ -452,7 +528,9 @@ async fn render_markdown_impl(content: Entry<'static>, did: Did<'static>) -> Str
     let events: Vec<_> = StreamExt::collect(processor).await;
 
     let mut html_buf = String::new();
-    let _ = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf).run();
+    let writer = ClientWriter::<_, _, ()>::new(events.into_iter(), &mut html_buf)
+        .with_embed_provider(resolved_content);
+    writer.run().ok();
     html_buf
 }
 
@@ -688,7 +766,7 @@ pub fn use_entries_from_ufos() -> (
                                 let entry_json = serde_json::to_value(entry).ok()?;
                                 Some((view_json, entry_json, *time))
                             })
-                            .collect::>()
+                            .collect::>(),
                     )
                 }
                 Err(e) => {
@@ -727,16 +805,12 @@ pub fn use_entries_from_ufos() -> (
     let res = use_resource(move || {
         let fetcher = fetcher.clone();
         async move {
-            fetcher
-                .fetch_entries_from_ufos()
-                .await
-                .ok()
-                .map(|entries| {
-                    entries
-                        .iter()
-                        .map(|arc| arc.as_ref().clone())
-                        .collect::>()
-                })
+            fetcher.fetch_entries_from_ufos().await.ok().map(|entries| {
+                entries
+                    .iter()
+                    .map(|arc| arc.as_ref().clone())
+                    .collect::>()
+            })
         }
     });
     let memo = use_memo(move || res.read().clone().flatten());
@@ -960,7 +1034,16 @@ pub fn use_standalone_entry_data(
     ident: ReadSignal>,
     rkey: ReadSignal,
 ) -> (
-    Result)>>, RenderError>,
+    Result<
+        Resource<
+            Option<(
+                serde_json::Value,
+                serde_json::Value,
+                Option<(serde_json::Value, serde_json::Value)>,
+            )>,
+        >,
+        RenderError,
+    >,
     Memo>,
 ) {
     let fetcher = use_context::();
@@ -991,11 +1074,16 @@ pub fn use_standalone_entry_data(
                     }
                     let entry_json = serde_json::to_value(&data.entry).ok()?;
                     let entry_view_json = serde_json::to_value(&data.entry_view).ok()?;
-                    let notebook_ctx_json = data.notebook_context.as_ref().map(|ctx| {
-                        let notebook_json = serde_json::to_value(&ctx.notebook).ok()?;
-                        let book_entry_json = serde_json::to_value(&ctx.book_entry_view).ok()?;
-                        Some((notebook_json, book_entry_json))
-                    }).flatten();
+                    let notebook_ctx_json = data
+                        .notebook_context
+                        .as_ref()
+                        .map(|ctx| {
+                            let notebook_json = serde_json::to_value(&ctx.notebook).ok()?;
+                            let book_entry_json =
+                                serde_json::to_value(&ctx.book_entry_view).ok()?;
+                            Some((notebook_json, book_entry_json))
+                        })
+                        .flatten();
                     Some((entry_json, entry_view_json, notebook_ctx_json))
                 }
                 Ok(None) => None,
@@ -1008,23 +1096,38 @@ pub fn use_standalone_entry_data(
     }));
 
     let memo = use_memo(use_reactive!(|res| {
-        use crate::fetch::{StandaloneEntryData, NotebookContext};
-        use weaver_api::sh_weaver::notebook::{EntryView, entry::Entry, BookEntryView, NotebookView};
+        use crate::fetch::{NotebookContext, StandaloneEntryData};
+        use weaver_api::sh_weaver::notebook::{
+            BookEntryView, EntryView, NotebookView, entry::Entry,
+        };
 
         let res = res.as_ref().ok()?;
-        let Some(Some((entry_json, entry_view_json, notebook_ctx_json))) = res.read().clone() else {
+        let Some(Some((entry_json, entry_view_json, notebook_ctx_json))) = res.read().clone()
+        else {
             return None;
         };
 
         let entry: Entry<'static> = jacquard::from_json_value::(entry_json).ok()?;
-        let entry_view: EntryView<'static> = jacquard::from_json_value::(entry_view_json).ok()?;
-        let notebook_context = notebook_ctx_json.map(|(notebook_json, book_entry_json)| {
-            let notebook: NotebookView<'static> = jacquard::from_json_value::(notebook_json).ok()?;
-            let book_entry_view: BookEntryView<'static> = jacquard::from_json_value::(book_entry_json).ok()?;
-            Some(NotebookContext { notebook, book_entry_view })
-        }).flatten();
-
-        Some(StandaloneEntryData { entry, entry_view, notebook_context })
+        let entry_view: EntryView<'static> =
+            jacquard::from_json_value::(entry_view_json).ok()?;
+        let notebook_context = notebook_ctx_json
+            .map(|(notebook_json, book_entry_json)| {
+                let notebook: NotebookView<'static> =
+                    jacquard::from_json_value::(notebook_json).ok()?;
+                let book_entry_view: BookEntryView<'static> =
+                    jacquard::from_json_value::(book_entry_json).ok()?;
+                Some(NotebookContext {
+                    notebook,
+                    book_entry_view,
+                })
+            })
+            .flatten();
+
+        Some(StandaloneEntryData {
+            entry,
+            entry_view,
+            notebook_context,
+        })
     }));
 
     (res, memo)
@@ -1069,7 +1172,10 @@ pub fn use_notebook_entry_by_rkey(
     let res = use_server_future(use_reactive!(|(ident, book_title, rkey)| {
         let fetcher = fetcher.clone();
         async move {
-            match fetcher.get_notebook_entry_by_rkey(ident(), book_title(), rkey()).await {
+            match fetcher
+                .get_notebook_entry_by_rkey(ident(), book_title(), rkey())
+                .await
+            {
                 Ok(Some(data)) => {
                     let book_entry_json = serde_json::to_value(&data.0).ok()?;
                     let entry_json = serde_json::to_value(&data.1).ok()?;
@@ -1087,8 +1193,10 @@ pub fn use_notebook_entry_by_rkey(
     let memo = use_memo(use_reactive!(|res| {
         let res = res.as_ref().ok()?;
         if let Some(Some((book_entry_json, entry_json))) = &*res.read() {
-            let book_entry: BookEntryView<'static> = jacquard::from_json_value::(book_entry_json.clone()).ok()?;
-            let entry: Entry<'static> = jacquard::from_json_value::(entry_json.clone()).ok()?;
+            let book_entry: BookEntryView<'static> =
+                jacquard::from_json_value::(book_entry_json.clone()).ok()?;
+            let entry: Entry<'static> =
+                jacquard::from_json_value::(entry_json.clone()).ok()?;
             Some((book_entry, entry))
         } else {
             None
diff --git a/crates/weaver-app/src/main.rs b/crates/weaver-app/src/main.rs
index fddd910..c28a3c6 100644
--- a/crates/weaver-app/src/main.rs
+++ b/crates/weaver-app/src/main.rs
@@ -129,12 +129,13 @@ fn main() {
         use tracing::Level;
         use tracing::subscriber::set_global_default;
         use tracing_subscriber::Registry;
+        use tracing_subscriber::filter::EnvFilter;
         use tracing_subscriber::layer::SubscriberExt;
 
         let console_level = if cfg!(debug_assertions) {
             Level::DEBUG
         } else {
-            Level::DEBUG
+            Level::INFO
         };
 
         let wasm_layer = tracing_wasm::WASMLayer::new(
@@ -143,7 +144,11 @@ fn main() {
                 .build(),
         );
 
+        // Filter out noisy crates
+        let filter = EnvFilter::new("debug,loro_internal=warn");
+
         let reg = Registry::default()
+            .with(filter)
             .with(wasm_layer)
             .with(components::editor::LogCaptureLayer);
 
@@ -319,674 +324,6 @@ pub async fn favicon() -> axum::response::Response {
     ([(CONTENT_TYPE, "image/jpg")], bytes).into_response()
 }
 
-/// Build an image response with appropriate headers for immutable blobs.
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-fn build_image_response(bytes: jacquard::bytes::Bytes) -> axum::response::Response {
-    use axum::{
-        http::header::{CACHE_CONTROL, CONTENT_TYPE},
-        response::IntoResponse,
-    };
-    use mime_sniffer::MimeTypeSniffer;
-
-    let mime = bytes.sniff_mime_type().unwrap_or("image/jpg").to_string();
-    (
-        [
-            (CONTENT_TYPE, mime),
-            (
-                CACHE_CONTROL,
-                "public, max-age=31536000, immutable".to_string(),
-            ),
-        ],
-        bytes,
-    )
-        .into_response()
-}
-
-/// Return a 404 response for missing images.
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-fn image_not_found() -> axum::response::Response {
-    use axum::{http::StatusCode, response::IntoResponse};
-    (StatusCode::NOT_FOUND, "Image not found").into_response()
-}
-
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/{notebook}/image/{name}", blob_cache: Extension>)]
-pub async fn image_named(notebook: SmolStr, name: SmolStr) -> Result {
-    if let Some(bytes) = blob_cache.get_named(&name) {
-        return Ok(build_image_response(bytes));
-    }
-
-    // Try to resolve from notebook
-    match blob_cache.resolve_from_notebook(¬ebook, &name).await {
-        Ok(bytes) => Ok(build_image_response(bytes)),
-        Err(_) => Ok(image_not_found()),
-    }
-}
-
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/{_notebook}/blob/{cid}", blob_cache: Extension>)]
-pub async fn blob(_notebook: SmolStr, cid: SmolStr) -> Result {
-    match Cid::new_owned(cid.as_bytes()) {
-        Ok(cid) => {
-            if let Some(bytes) = blob_cache.get_cid(&cid) {
-                Ok(build_image_response(bytes))
-            } else {
-                Ok(image_not_found())
-            }
-        }
-        Err(_) => Ok(image_not_found()),
-    }
-}
-
-// Route: /image/{notebook}/{name} - notebook entry image by name
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/image/{notebook}/{name}", blob_cache: Extension>)]
-pub async fn image_notebook(notebook: SmolStr, name: SmolStr) -> Result {
-    // Try name-based lookup first (backwards compat with cached entries)
-    if let Some(bytes) = blob_cache.get_named(&name) {
-        return Ok(build_image_response(bytes));
-    }
-
-    // Try to resolve from notebook
-    match blob_cache.resolve_from_notebook(¬ebook, &name).await {
-        Ok(bytes) => Ok(build_image_response(bytes)),
-        Err(_) => Ok(image_not_found()),
-    }
-}
-
-// Route: /image/{ident}/draft/{blob_rkey} - draft image (unpublished)
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/image/{ident}/draft/{blob_rkey}", blob_cache: Extension>)]
-pub async fn image_draft(ident: SmolStr, blob_rkey: SmolStr) -> Result {
-    let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else {
-        return Ok(image_not_found());
-    };
-
-    match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await {
-        Ok(bytes) => Ok(build_image_response(bytes)),
-        Err(_) => Ok(image_not_found()),
-    }
-}
-
-// Route: /image/{ident}/draft/{blob_rkey}/{name} - draft image with name (name is decorative)
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/image/{ident}/draft/{blob_rkey}/{_name}", blob_cache: Extension>)]
-pub async fn image_draft_named(
-    ident: SmolStr,
-    blob_rkey: SmolStr,
-    _name: SmolStr,
-) -> Result {
-    let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else {
-        return Ok(image_not_found());
-    };
-
-    match blob_cache.resolve_from_draft(&at_ident, &blob_rkey).await {
-        Ok(bytes) => Ok(build_image_response(bytes)),
-        Err(_) => Ok(image_not_found()),
-    }
-}
-
-// Route: /image/{ident}/{rkey}/{name} - published entry image
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/image/{ident}/{rkey}/{name}", blob_cache: Extension>)]
-pub async fn image_entry(
-    ident: SmolStr,
-    rkey: SmolStr,
-    name: SmolStr,
-) -> Result {
-    let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else {
-        return Ok(image_not_found());
-    };
-
-    match blob_cache.resolve_from_entry(&at_ident, &rkey, &name).await {
-        Ok(bytes) => Ok(build_image_response(bytes)),
-        Err(_) => Ok(image_not_found()),
-    }
-}
-
-// Route: /og/{ident}/{book_title}/{entry_title} - OpenGraph image for entry
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/og/{ident}/{book_title}/{entry_title}", fetcher: Extension>)]
-pub async fn og_image(
-    ident: SmolStr,
-    book_title: SmolStr,
-    entry_title: SmolStr,
-) -> Result {
-    use axum::{
-        http::{
-            StatusCode,
-            header::{CACHE_CONTROL, CONTENT_TYPE},
-        },
-        response::IntoResponse,
-    };
-    use weaver_api::sh_weaver::actor::ProfileDataViewInner;
-    use weaver_api::sh_weaver::notebook::Title;
-
-    // Strip .png extension if present
-    let entry_title = entry_title.strip_suffix(".png").unwrap_or(&entry_title);
-
-    let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else {
-        return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response());
-    };
-
-    // Fetch entry data
-    let entry_result = fetcher
-        .get_entry(at_ident.clone(), book_title.clone(), entry_title.into())
-        .await;
-
-    let arc_data = match entry_result {
-        Ok(Some(data)) => data,
-        Ok(None) => return Ok((StatusCode::NOT_FOUND, "Entry not found").into_response()),
-        Err(e) => {
-            tracing::error!("Failed to fetch entry for OG image: {:?}", e);
-            return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch entry").into_response());
-        }
-    };
-    let (book_entry, entry) = arc_data.as_ref();
-
-    // Build cache key using entry CID
-    let entry_cid = book_entry.entry.cid.as_ref();
-    let cache_key = og::cache_key(&ident, &book_title, entry_title, entry_cid);
-
-    // Check cache first
-    if let Some(cached) = og::get_cached(&cache_key) {
-        return Ok((
-            [
-                (CONTENT_TYPE, "image/png"),
-                (CACHE_CONTROL, "public, max-age=3600"),
-            ],
-            cached,
-        )
-            .into_response());
-    }
-
-    // Extract metadata
-    let title: &str = entry.title.as_ref();
-
-    // Use book_title from URL - it's the notebook slug/title
-    // TODO: Could fetch actual notebook record to get display title
-    let notebook_title_str: String = book_title.to_string();
-
-    let author_handle = book_entry
-        .entry
-        .authors
-        .first()
-        .map(|a| match &a.record.inner {
-            ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(),
-            ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(),
-            ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(),
-            _ => "unknown".to_string(),
-        })
-        .unwrap_or_else(|| "unknown".to_string());
-
-    // Check for hero image in embeds
-    let hero_image_data = if let Some(ref embeds) = entry.embeds {
-        if let Some(ref images) = embeds.images {
-            if let Some(first_image) = images.images.first() {
-                // Get DID from the entry URI
-                let did = book_entry.entry.uri.authority();
-
-                let blob = first_image.image.blob();
-                let cid = blob.cid();
-                let mime = blob.mime_type.as_ref();
-                let format = mime.strip_prefix("image/").unwrap_or("jpeg");
-
-                // Build CDN URL
-                let cdn_url = format!(
-                    "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}",
-                    did.as_str(),
-                    cid.as_ref(),
-                    format
-                );
-
-                // Fetch the image
-                match reqwest::get(&cdn_url).await {
-                    Ok(response) if response.status().is_success() => {
-                        match response.bytes().await {
-                            Ok(bytes) => {
-                                use base64::Engine;
-                                let base64_str =
-                                    base64::engine::general_purpose::STANDARD.encode(&bytes);
-                                Some(format!("data:{};base64,{}", mime, base64_str))
-                            }
-                            Err(_) => None,
-                        }
-                    }
-                    _ => None,
-                }
-            } else {
-                None
-            }
-        } else {
-            None
-        }
-    } else {
-        None
-    };
-
-    // Extract content snippet - render markdown to HTML then strip tags
-    let content_snippet: String = {
-        let parser = markdown_weaver::Parser::new(entry.content.as_ref());
-        let mut html = String::new();
-        markdown_weaver::html::push_html(&mut html, parser);
-        // Strip HTML tags
-        regex_lite::Regex::new(r"<[^>]+>")
-            .unwrap()
-            .replace_all(&html, "")
-            .replace("&", "&")
-            .replace("<", "<")
-            .replace(">", ">")
-            .replace(""", "\"")
-            .replace("'", "'")
-            .replace('\n', " ")
-            .split_whitespace()
-            .collect::>()
-            .join(" ")
-    };
-
-    // Generate image - hero or text-only based on available data
-    let png_bytes = if let Some(ref hero_data) = hero_image_data {
-        match og::generate_hero_image(hero_data, title, ¬ebook_title_str, &author_handle) {
-            Ok(bytes) => bytes,
-            Err(e) => {
-                tracing::error!(
-                    "Failed to generate hero OG image: {:?}, falling back to text",
-                    e
-                );
-                og::generate_text_only(title, &content_snippet, ¬ebook_title_str, &author_handle)
-                    .map_err(|e| {
-                        tracing::error!("Failed to generate text OG image: {:?}", e);
-                    })
-                    .ok()
-                    .unwrap_or_default()
-            }
-        }
-    } else {
-        match og::generate_text_only(title, &content_snippet, ¬ebook_title_str, &author_handle) {
-            Ok(bytes) => bytes,
-            Err(e) => {
-                tracing::error!("Failed to generate OG image: {:?}", e);
-                return Ok((
-                    StatusCode::INTERNAL_SERVER_ERROR,
-                    "Failed to generate image",
-                )
-                    .into_response());
-            }
-        }
-    };
-
-    // Cache the generated image
-    og::cache_image(cache_key, png_bytes.clone());
-
-    Ok((
-        [
-            (CONTENT_TYPE, "image/png"),
-            (CACHE_CONTROL, "public, max-age=3600"),
-        ],
-        png_bytes,
-    )
-        .into_response())
-}
-
-// Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/og/notebook/{ident}/{book_title}", fetcher: Extension>)]
-pub async fn og_notebook_image(
-    ident: SmolStr,
-    book_title: SmolStr,
-) -> Result {
-    use axum::{
-        http::{
-            StatusCode,
-            header::{CACHE_CONTROL, CONTENT_TYPE},
-        },
-        response::IntoResponse,
-    };
-    use weaver_api::sh_weaver::actor::ProfileDataViewInner;
-
-    // Strip .png extension if present
-    let book_title = book_title.strip_suffix(".png").unwrap_or(&book_title);
-
-    let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else {
-        return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response());
-    };
-
-    // Fetch notebook data
-    let notebook_result = fetcher
-        .get_notebook(at_ident.clone(), book_title.into())
-        .await;
-
-    let arc_data = match notebook_result {
-        Ok(Some(data)) => data,
-        Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()),
-        Err(e) => {
-            tracing::error!("Failed to fetch notebook for OG image: {:?}", e);
-            return Ok((
-                StatusCode::INTERNAL_SERVER_ERROR,
-                "Failed to fetch notebook",
-            )
-                .into_response());
-        }
-    };
-    let (notebook_view, _entries) = arc_data.as_ref();
-
-    // Build cache key using notebook CID
-    let notebook_cid = notebook_view.cid.as_ref();
-    let cache_key = og::notebook_cache_key(&ident, book_title, notebook_cid);
-
-    // Check cache first
-    if let Some(cached) = og::get_cached(&cache_key) {
-        return Ok((
-            [
-                (CONTENT_TYPE, "image/png"),
-                (CACHE_CONTROL, "public, max-age=3600"),
-            ],
-            cached,
-        )
-            .into_response());
-    }
-
-    // Extract metadata
-    let title = notebook_view
-        .title
-        .as_ref()
-        .map(|t| t.as_ref())
-        .unwrap_or("Untitled Notebook");
-
-    let author_handle = notebook_view
-        .authors
-        .first()
-        .map(|a| match &a.record.inner {
-            ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(),
-            ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(),
-            ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(),
-            _ => "unknown".to_string(),
-        })
-        .unwrap_or_else(|| "unknown".to_string());
-
-    // Fetch entries to get entry titles and count
-    let entries_result = fetcher
-        .list_notebook_entries(at_ident.clone(), book_title.into())
-        .await;
-    let (entry_count, entry_titles) = match entries_result {
-        Ok(Some(entries)) => {
-            let count = entries.len();
-            let titles: Vec = entries
-                .iter()
-                .take(4)
-                .map(|e| {
-                    e.entry
-                        .title
-                        .as_ref()
-                        .map(|t| t.as_ref().to_string())
-                        .unwrap_or_else(|| "Untitled".to_string())
-                })
-                .collect();
-            (count, titles)
-        }
-        _ => (0, vec![]),
-    };
-
-    // Generate image
-    let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles)
-    {
-        Ok(bytes) => bytes,
-        Err(e) => {
-            tracing::error!("Failed to generate notebook OG image: {:?}", e);
-            return Ok((
-                StatusCode::INTERNAL_SERVER_ERROR,
-                "Failed to generate image",
-            )
-                .into_response());
-        }
-    };
-
-    // Cache the generated image
-    og::cache_image(cache_key, png_bytes.clone());
-
-    Ok((
-        [
-            (CONTENT_TYPE, "image/png"),
-            (CACHE_CONTROL, "public, max-age=3600"),
-        ],
-        png_bytes,
-    )
-        .into_response())
-}
-
-// Route: /og/profile/{ident}.png - OpenGraph image for profile/repository
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/og/profile/{ident}", fetcher: Extension>)]
-pub async fn og_profile_image(ident: SmolStr) -> Result {
-    use axum::{
-        http::{
-            StatusCode,
-            header::{CACHE_CONTROL, CONTENT_TYPE},
-        },
-        response::IntoResponse,
-    };
-    use weaver_api::sh_weaver::actor::ProfileDataViewInner;
-
-    // Strip .png extension if present
-    let ident = ident.strip_suffix(".png").unwrap_or(&ident);
-
-    let Ok(at_ident) = AtIdentifier::new_owned(ident.to_string()) else {
-        return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response());
-    };
-
-    // Fetch profile data
-    let profile_result = fetcher.fetch_profile(&at_ident).await;
-
-    let profile_view = match profile_result {
-        Ok(data) => data,
-        Err(e) => {
-            tracing::error!("Failed to fetch profile for OG image: {:?}", e);
-            return Ok(
-                (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response(),
-            );
-        }
-    };
-
-    // Extract profile fields based on type
-    // Use DID as cache key since profiles don't have a CID field
-    let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner {
-        ProfileDataViewInner::ProfileView(p) => (
-            p.display_name
-                .as_ref()
-                .map(|n| n.as_ref().to_string())
-                .unwrap_or_default(),
-            p.handle.as_ref().to_string(),
-            p.description
-                .as_ref()
-                .map(|d| d.as_ref().to_string())
-                .unwrap_or_default(),
-            p.avatar.as_ref().map(|u| u.as_ref().to_string()),
-            None::,
-            p.did.as_ref().to_string(),
-        ),
-        ProfileDataViewInner::ProfileViewDetailed(p) => (
-            p.display_name
-                .as_ref()
-                .map(|n| n.as_ref().to_string())
-                .unwrap_or_default(),
-            p.handle.as_ref().to_string(),
-            p.description
-                .as_ref()
-                .map(|d| d.as_ref().to_string())
-                .unwrap_or_default(),
-            p.avatar.as_ref().map(|u| u.as_ref().to_string()),
-            p.banner.as_ref().map(|u| u.as_ref().to_string()),
-            p.did.as_ref().to_string(),
-        ),
-        ProfileDataViewInner::TangledProfileView(p) => (
-            String::new(),
-            p.handle.as_ref().to_string(),
-            String::new(),
-            None,
-            None,
-            p.did.as_ref().to_string(),
-        ),
-        _ => return Ok((StatusCode::NOT_FOUND, "Profile type not supported").into_response()),
-    };
-
-    // Build cache key
-    let cache_key = og::profile_cache_key(ident, &cache_id);
-
-    // Check cache first
-    if let Some(cached) = og::get_cached(&cache_key) {
-        return Ok((
-            [
-                (CONTENT_TYPE, "image/png"),
-                (CACHE_CONTROL, "public, max-age=3600"),
-            ],
-            cached,
-        )
-            .into_response());
-    }
-
-    // Fetch notebook count
-    let notebooks_result = fetcher.fetch_notebooks_for_did(&at_ident).await;
-    let notebook_count = notebooks_result.map(|n| n.len()).unwrap_or(0);
-
-    // Fetch avatar as base64 if available
-    let avatar_data = if let Some(ref url) = avatar_url {
-        match reqwest::get(url).await {
-            Ok(response) if response.status().is_success() => {
-                let content_type = response
-                    .headers()
-                    .get("content-type")
-                    .and_then(|v| v.to_str().ok())
-                    .unwrap_or("image/jpeg")
-                    .to_string();
-                match response.bytes().await {
-                    Ok(bytes) => {
-                        use base64::Engine;
-                        let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes);
-                        Some(format!("data:{};base64,{}", content_type, base64_str))
-                    }
-                    Err(_) => None,
-                }
-            }
-            _ => None,
-        }
-    } else {
-        None
-    };
-
-    // Check for banner and generate appropriate template
-    let png_bytes = if let Some(ref banner_url) = banner_url {
-        // Fetch banner image
-        let banner_data = match reqwest::get(banner_url).await {
-            Ok(response) if response.status().is_success() => {
-                let content_type = response
-                    .headers()
-                    .get("content-type")
-                    .and_then(|v| v.to_str().ok())
-                    .unwrap_or("image/jpeg")
-                    .to_string();
-                match response.bytes().await {
-                    Ok(bytes) => {
-                        use base64::Engine;
-                        let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes);
-                        Some(format!("data:{};base64,{}", content_type, base64_str))
-                    }
-                    Err(_) => None,
-                }
-            }
-            _ => None,
-        };
-
-        if let Some(banner_data) = banner_data {
-            match og::generate_profile_banner_og(
-                &display_name,
-                &handle,
-                &bio,
-                banner_data,
-                avatar_data.clone(),
-                notebook_count,
-            ) {
-                Ok(bytes) => bytes,
-                Err(e) => {
-                    tracing::error!(
-                        "Failed to generate profile banner OG image: {:?}, falling back",
-                        e
-                    );
-                    og::generate_profile_og(
-                        &display_name,
-                        &handle,
-                        &bio,
-                        avatar_data,
-                        notebook_count,
-                    )
-                    .unwrap_or_default()
-                }
-            }
-        } else {
-            og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count)
-                .unwrap_or_default()
-        }
-    } else {
-        match og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) {
-            Ok(bytes) => bytes,
-            Err(e) => {
-                tracing::error!("Failed to generate profile OG image: {:?}", e);
-                return Ok((
-                    StatusCode::INTERNAL_SERVER_ERROR,
-                    "Failed to generate image",
-                )
-                    .into_response());
-            }
-        }
-    };
-
-    // Cache the generated image
-    og::cache_image(cache_key, png_bytes.clone());
-
-    Ok((
-        [
-            (CONTENT_TYPE, "image/png"),
-            (CACHE_CONTROL, "public, max-age=3600"),
-        ],
-        png_bytes,
-    )
-        .into_response())
-}
-
-// Route: /og/site.png - OpenGraph image for homepage
-#[cfg(all(feature = "fullstack-server", feature = "server"))]
-#[get("/og/site.png")]
-pub async fn og_site_image() -> Result {
-    use axum::{
-        http::{
-            StatusCode,
-            header::{CACHE_CONTROL, CONTENT_TYPE},
-        },
-        response::IntoResponse,
-    };
-
-    // Site OG is static, cache aggressively
-    static SITE_OG_CACHE: std::sync::OnceLock> = std::sync::OnceLock::new();
-
-    let png_bytes = SITE_OG_CACHE.get_or_init(|| og::generate_site_og().unwrap_or_default());
-
-    if png_bytes.is_empty() {
-        return Ok((
-            StatusCode::INTERNAL_SERVER_ERROR,
-            "Failed to generate image",
-        )
-            .into_response());
-    }
-
-    Ok((
-        [
-            (CONTENT_TYPE, "image/png"),
-            (CACHE_CONTROL, "public, max-age=86400"),
-        ],
-        png_bytes.clone(),
-    )
-        .into_response())
-}
-
 // #[server(endpoint = "static_routes", output = server_fn::codec::Json)]
 // async fn static_routes() -> Result, ServerFnError> {
 //     // The `Routable` trait has a `static_routes` method that returns all static routes in the enum
diff --git a/crates/weaver-app/src/og/mod.rs b/crates/weaver-app/src/og/mod.rs
index 6087bc1..b4f9865 100644
--- a/crates/weaver-app/src/og/mod.rs
+++ b/crates/weaver-app/src/og/mod.rs
@@ -1,13 +1,13 @@
 //! OpenGraph image generation module
 //!
 //! Generates social card images for entry pages using SVG templates rendered to PNG.
+pub mod server;
 
+use crate::cache_impl::{Cache, new_cache};
 use askama::Template;
 use std::sync::OnceLock;
 use std::time::Duration;
 
-use crate::cache_impl::{Cache, new_cache};
-
 /// Cache for generated OG images
 /// Key: "{ident}/{book}/{entry}/{cid}" - includes CID for invalidation
 static OG_CACHE: OnceLock>> = OnceLock::new();
@@ -139,8 +139,7 @@ fn get_fontdb() -> &'static fontdb::Database {
         // Load IBM Plex Sans Bold (static weight for proper bold rendering)
         let font_data = include_bytes!("../../assets/fonts/IBMPlexSans-Bold.ttf");
         db.load_font_data(font_data.to_vec());
-        let font_data =
-            include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.ttf");
+        let font_data = include_bytes!("../../assets/fonts/ioskeley-mono/IoskeleyMono-Regular.ttf");
         db.load_font_data(font_data.to_vec());
         db
     })
diff --git a/crates/weaver-app/src/og/server.rs b/crates/weaver-app/src/og/server.rs
new file mode 100644
index 0000000..4faf24f
--- /dev/null
+++ b/crates/weaver-app/src/og/server.rs
@@ -0,0 +1,557 @@
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+use crate::fetch;
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+use crate::og;
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+use axum::Extension;
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+use dioxus::prelude::*;
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+use jacquard::smol_str::SmolStr;
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+use jacquard::types::string::AtIdentifier;
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+use std::sync::Arc;
+
+// Route: /og/{ident}/{book_title}/{entry_title} - OpenGraph image for entry
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+#[get("/og/{ident}/{book_title}/{entry_title}", fetcher: Extension>)]
+pub async fn og_image(
+    ident: SmolStr,
+    book_title: SmolStr,
+    entry_title: SmolStr,
+) -> Result {
+    use axum::{
+        http::{
+            StatusCode,
+            header::{CACHE_CONTROL, CONTENT_TYPE},
+        },
+        response::IntoResponse,
+    };
+    use weaver_api::sh_weaver::actor::ProfileDataViewInner;
+    use weaver_api::sh_weaver::notebook::Title;
+
+    // Strip .png extension if present
+    let entry_title = entry_title.strip_suffix(".png").unwrap_or(&entry_title);
+
+    let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else {
+        return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response());
+    };
+
+    // Fetch entry data
+    let entry_result = fetcher
+        .get_entry(at_ident.clone(), book_title.clone(), entry_title.into())
+        .await;
+
+    let arc_data = match entry_result {
+        Ok(Some(data)) => data,
+        Ok(None) => return Ok((StatusCode::NOT_FOUND, "Entry not found").into_response()),
+        Err(e) => {
+            tracing::error!("Failed to fetch entry for OG image: {:?}", e);
+            return Ok((StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch entry").into_response());
+        }
+    };
+    let (book_entry, entry) = arc_data.as_ref();
+
+    // Build cache key using entry CID
+    let entry_cid = book_entry.entry.cid.as_ref();
+    let cache_key = og::cache_key(&ident, &book_title, entry_title, entry_cid);
+
+    // Check cache first
+    if let Some(cached) = og::get_cached(&cache_key) {
+        return Ok((
+            [
+                (CONTENT_TYPE, "image/png"),
+                (CACHE_CONTROL, "public, max-age=3600"),
+            ],
+            cached,
+        )
+            .into_response());
+    }
+
+    // Extract metadata
+    let title: &str = entry.title.as_ref();
+
+    // Use book_title from URL - it's the notebook slug/title
+    // TODO: Could fetch actual notebook record to get display title
+    let notebook_title_str: String = book_title.to_string();
+
+    let author_handle = book_entry
+        .entry
+        .authors
+        .first()
+        .map(|a| match &a.record.inner {
+            ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(),
+            ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(),
+            ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(),
+            _ => "unknown".to_string(),
+        })
+        .unwrap_or_else(|| "unknown".to_string());
+
+    // Check for hero image in embeds
+    let hero_image_data = if let Some(ref embeds) = entry.embeds {
+        if let Some(ref images) = embeds.images {
+            if let Some(first_image) = images.images.first() {
+                // Get DID from the entry URI
+                let did = book_entry.entry.uri.authority();
+
+                let blob = first_image.image.blob();
+                let cid = blob.cid();
+                let mime = blob.mime_type.as_ref();
+                let format = mime.strip_prefix("image/").unwrap_or("jpeg");
+
+                // Build CDN URL
+                let cdn_url = format!(
+                    "https://cdn.bsky.app/img/feed_fullsize/plain/{}/{}@{}",
+                    did.as_str(),
+                    cid.as_ref(),
+                    format
+                );
+
+                // Fetch the image
+                match reqwest::get(&cdn_url).await {
+                    Ok(response) if response.status().is_success() => {
+                        match response.bytes().await {
+                            Ok(bytes) => {
+                                use base64::Engine;
+                                let base64_str =
+                                    base64::engine::general_purpose::STANDARD.encode(&bytes);
+                                Some(format!("data:{};base64,{}", mime, base64_str))
+                            }
+                            Err(_) => None,
+                        }
+                    }
+                    _ => None,
+                }
+            } else {
+                None
+            }
+        } else {
+            None
+        }
+    } else {
+        None
+    };
+
+    // Extract content snippet - render markdown to HTML then strip tags
+    let content_snippet: String = {
+        let parser = markdown_weaver::Parser::new(entry.content.as_ref());
+        let mut html = String::new();
+        markdown_weaver::html::push_html(&mut html, parser);
+        // Strip HTML tags
+        regex_lite::Regex::new(r"<[^>]+>")
+            .unwrap()
+            .replace_all(&html, "")
+            .replace("&", "&")
+            .replace("<", "<")
+            .replace(">", ">")
+            .replace(""", "\"")
+            .replace("'", "'")
+            .replace('\n', " ")
+            .split_whitespace()
+            .collect::>()
+            .join(" ")
+    };
+
+    // Generate image - hero or text-only based on available data
+    let png_bytes = if let Some(ref hero_data) = hero_image_data {
+        match og::generate_hero_image(hero_data, title, ¬ebook_title_str, &author_handle) {
+            Ok(bytes) => bytes,
+            Err(e) => {
+                tracing::error!(
+                    "Failed to generate hero OG image: {:?}, falling back to text",
+                    e
+                );
+                og::generate_text_only(title, &content_snippet, ¬ebook_title_str, &author_handle)
+                    .map_err(|e| {
+                        tracing::error!("Failed to generate text OG image: {:?}", e);
+                    })
+                    .ok()
+                    .unwrap_or_default()
+            }
+        }
+    } else {
+        match og::generate_text_only(title, &content_snippet, ¬ebook_title_str, &author_handle) {
+            Ok(bytes) => bytes,
+            Err(e) => {
+                tracing::error!("Failed to generate OG image: {:?}", e);
+                return Ok((
+                    StatusCode::INTERNAL_SERVER_ERROR,
+                    "Failed to generate image",
+                )
+                    .into_response());
+            }
+        }
+    };
+
+    // Cache the generated image
+    og::cache_image(cache_key, png_bytes.clone());
+
+    Ok((
+        [
+            (CONTENT_TYPE, "image/png"),
+            (CACHE_CONTROL, "public, max-age=3600"),
+        ],
+        png_bytes,
+    )
+        .into_response())
+}
+
+// Route: /og/notebook/{ident}/{book_title}.png - OpenGraph image for notebook index
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+#[get("/og/notebook/{ident}/{book_title}", fetcher: Extension>)]
+pub async fn og_notebook_image(
+    ident: SmolStr,
+    book_title: SmolStr,
+) -> Result {
+    use axum::{
+        http::{
+            StatusCode,
+            header::{CACHE_CONTROL, CONTENT_TYPE},
+        },
+        response::IntoResponse,
+    };
+    use weaver_api::sh_weaver::actor::ProfileDataViewInner;
+
+    // Strip .png extension if present
+    let book_title = book_title.strip_suffix(".png").unwrap_or(&book_title);
+
+    let Ok(at_ident) = AtIdentifier::new_owned(ident.clone()) else {
+        return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response());
+    };
+
+    // Fetch notebook data
+    let notebook_result = fetcher
+        .get_notebook(at_ident.clone(), book_title.into())
+        .await;
+
+    let arc_data = match notebook_result {
+        Ok(Some(data)) => data,
+        Ok(None) => return Ok((StatusCode::NOT_FOUND, "Notebook not found").into_response()),
+        Err(e) => {
+            tracing::error!("Failed to fetch notebook for OG image: {:?}", e);
+            return Ok((
+                StatusCode::INTERNAL_SERVER_ERROR,
+                "Failed to fetch notebook",
+            )
+                .into_response());
+        }
+    };
+    let (notebook_view, _entries) = arc_data.as_ref();
+
+    // Build cache key using notebook CID
+    let notebook_cid = notebook_view.cid.as_ref();
+    let cache_key = og::notebook_cache_key(&ident, book_title, notebook_cid);
+
+    // Check cache first
+    if let Some(cached) = og::get_cached(&cache_key) {
+        return Ok((
+            [
+                (CONTENT_TYPE, "image/png"),
+                (CACHE_CONTROL, "public, max-age=3600"),
+            ],
+            cached,
+        )
+            .into_response());
+    }
+
+    // Extract metadata
+    let title = notebook_view
+        .title
+        .as_ref()
+        .map(|t| t.as_ref())
+        .unwrap_or("Untitled Notebook");
+
+    let author_handle = notebook_view
+        .authors
+        .first()
+        .map(|a| match &a.record.inner {
+            ProfileDataViewInner::ProfileView(p) => p.handle.as_ref().to_string(),
+            ProfileDataViewInner::ProfileViewDetailed(p) => p.handle.as_ref().to_string(),
+            ProfileDataViewInner::TangledProfileView(p) => p.handle.as_ref().to_string(),
+            _ => "unknown".to_string(),
+        })
+        .unwrap_or_else(|| "unknown".to_string());
+
+    // Fetch entries to get entry titles and count
+    let entries_result = fetcher
+        .list_notebook_entries(at_ident.clone(), book_title.into())
+        .await;
+    let (entry_count, entry_titles) = match entries_result {
+        Ok(Some(entries)) => {
+            let count = entries.len();
+            let titles: Vec = entries
+                .iter()
+                .take(4)
+                .map(|e| {
+                    e.entry
+                        .title
+                        .as_ref()
+                        .map(|t| t.as_ref().to_string())
+                        .unwrap_or_else(|| "Untitled".to_string())
+                })
+                .collect();
+            (count, titles)
+        }
+        _ => (0, vec![]),
+    };
+
+    // Generate image
+    let png_bytes = match og::generate_notebook_og(title, &author_handle, entry_count, entry_titles)
+    {
+        Ok(bytes) => bytes,
+        Err(e) => {
+            tracing::error!("Failed to generate notebook OG image: {:?}", e);
+            return Ok((
+                StatusCode::INTERNAL_SERVER_ERROR,
+                "Failed to generate image",
+            )
+                .into_response());
+        }
+    };
+
+    // Cache the generated image
+    og::cache_image(cache_key, png_bytes.clone());
+
+    Ok((
+        [
+            (CONTENT_TYPE, "image/png"),
+            (CACHE_CONTROL, "public, max-age=3600"),
+        ],
+        png_bytes,
+    )
+        .into_response())
+}
+
+// Route: /og/profile/{ident}.png - OpenGraph image for profile/repository
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+#[get("/og/profile/{ident}", fetcher: Extension>)]
+pub async fn og_profile_image(ident: SmolStr) -> Result {
+    use axum::{
+        http::{
+            StatusCode,
+            header::{CACHE_CONTROL, CONTENT_TYPE},
+        },
+        response::IntoResponse,
+    };
+    use weaver_api::sh_weaver::actor::ProfileDataViewInner;
+
+    // Strip .png extension if present
+    let ident = ident.strip_suffix(".png").unwrap_or(&ident);
+
+    let Ok(at_ident) = AtIdentifier::new_owned(ident.to_string()) else {
+        return Ok((StatusCode::BAD_REQUEST, "Invalid identifier").into_response());
+    };
+
+    // Fetch profile data
+    let profile_result = fetcher.fetch_profile(&at_ident).await;
+
+    let profile_view = match profile_result {
+        Ok(data) => data,
+        Err(e) => {
+            tracing::error!("Failed to fetch profile for OG image: {:?}", e);
+            return Ok(
+                (StatusCode::INTERNAL_SERVER_ERROR, "Failed to fetch profile").into_response(),
+            );
+        }
+    };
+
+    // Extract profile fields based on type
+    // Use DID as cache key since profiles don't have a CID field
+    let (display_name, handle, bio, avatar_url, banner_url, cache_id) = match &profile_view.inner {
+        ProfileDataViewInner::ProfileView(p) => (
+            p.display_name
+                .as_ref()
+                .map(|n| n.as_ref().to_string())
+                .unwrap_or_default(),
+            p.handle.as_ref().to_string(),
+            p.description
+                .as_ref()
+                .map(|d| d.as_ref().to_string())
+                .unwrap_or_default(),
+            p.avatar.as_ref().map(|u| u.as_ref().to_string()),
+            None::,
+            p.did.as_ref().to_string(),
+        ),
+        ProfileDataViewInner::ProfileViewDetailed(p) => (
+            p.display_name
+                .as_ref()
+                .map(|n| n.as_ref().to_string())
+                .unwrap_or_default(),
+            p.handle.as_ref().to_string(),
+            p.description
+                .as_ref()
+                .map(|d| d.as_ref().to_string())
+                .unwrap_or_default(),
+            p.avatar.as_ref().map(|u| u.as_ref().to_string()),
+            p.banner.as_ref().map(|u| u.as_ref().to_string()),
+            p.did.as_ref().to_string(),
+        ),
+        ProfileDataViewInner::TangledProfileView(p) => (
+            String::new(),
+            p.handle.as_ref().to_string(),
+            String::new(),
+            None,
+            None,
+            p.did.as_ref().to_string(),
+        ),
+        _ => return Ok((StatusCode::NOT_FOUND, "Profile type not supported").into_response()),
+    };
+
+    // Build cache key
+    let cache_key = og::profile_cache_key(ident, &cache_id);
+
+    // Check cache first
+    if let Some(cached) = og::get_cached(&cache_key) {
+        return Ok((
+            [
+                (CONTENT_TYPE, "image/png"),
+                (CACHE_CONTROL, "public, max-age=3600"),
+            ],
+            cached,
+        )
+            .into_response());
+    }
+
+    // Fetch notebook count
+    let notebooks_result = fetcher.fetch_notebooks_for_did(&at_ident).await;
+    let notebook_count = notebooks_result.map(|n| n.len()).unwrap_or(0);
+
+    // Fetch avatar as base64 if available
+    let avatar_data = if let Some(ref url) = avatar_url {
+        match reqwest::get(url).await {
+            Ok(response) if response.status().is_success() => {
+                let content_type = response
+                    .headers()
+                    .get("content-type")
+                    .and_then(|v| v.to_str().ok())
+                    .unwrap_or("image/jpeg")
+                    .to_string();
+                match response.bytes().await {
+                    Ok(bytes) => {
+                        use base64::Engine;
+                        let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes);
+                        Some(format!("data:{};base64,{}", content_type, base64_str))
+                    }
+                    Err(_) => None,
+                }
+            }
+            _ => None,
+        }
+    } else {
+        None
+    };
+
+    // Check for banner and generate appropriate template
+    let png_bytes = if let Some(ref banner_url) = banner_url {
+        // Fetch banner image
+        let banner_data = match reqwest::get(banner_url).await {
+            Ok(response) if response.status().is_success() => {
+                let content_type = response
+                    .headers()
+                    .get("content-type")
+                    .and_then(|v| v.to_str().ok())
+                    .unwrap_or("image/jpeg")
+                    .to_string();
+                match response.bytes().await {
+                    Ok(bytes) => {
+                        use base64::Engine;
+                        let base64_str = base64::engine::general_purpose::STANDARD.encode(&bytes);
+                        Some(format!("data:{};base64,{}", content_type, base64_str))
+                    }
+                    Err(_) => None,
+                }
+            }
+            _ => None,
+        };
+
+        if let Some(banner_data) = banner_data {
+            match og::generate_profile_banner_og(
+                &display_name,
+                &handle,
+                &bio,
+                banner_data,
+                avatar_data.clone(),
+                notebook_count,
+            ) {
+                Ok(bytes) => bytes,
+                Err(e) => {
+                    tracing::error!(
+                        "Failed to generate profile banner OG image: {:?}, falling back",
+                        e
+                    );
+                    og::generate_profile_og(
+                        &display_name,
+                        &handle,
+                        &bio,
+                        avatar_data,
+                        notebook_count,
+                    )
+                    .unwrap_or_default()
+                }
+            }
+        } else {
+            og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count)
+                .unwrap_or_default()
+        }
+    } else {
+        match og::generate_profile_og(&display_name, &handle, &bio, avatar_data, notebook_count) {
+            Ok(bytes) => bytes,
+            Err(e) => {
+                tracing::error!("Failed to generate profile OG image: {:?}", e);
+                return Ok((
+                    StatusCode::INTERNAL_SERVER_ERROR,
+                    "Failed to generate image",
+                )
+                    .into_response());
+            }
+        }
+    };
+
+    // Cache the generated image
+    og::cache_image(cache_key, png_bytes.clone());
+
+    Ok((
+        [
+            (CONTENT_TYPE, "image/png"),
+            (CACHE_CONTROL, "public, max-age=3600"),
+        ],
+        png_bytes,
+    )
+        .into_response())
+}
+
+// Route: /og/site.png - OpenGraph image for homepage
+#[cfg(all(feature = "fullstack-server", feature = "server"))]
+#[get("/og/site.png")]
+pub async fn og_site_image() -> Result {
+    use axum::{
+        http::{
+            StatusCode,
+            header::{CACHE_CONTROL, CONTENT_TYPE},
+        },
+        response::IntoResponse,
+    };
+
+    // Site OG is static, cache aggressively
+    static SITE_OG_CACHE: std::sync::OnceLock> = std::sync::OnceLock::new();
+
+    let png_bytes = SITE_OG_CACHE.get_or_init(|| og::generate_site_og().unwrap_or_default());
+
+    if png_bytes.is_empty() {
+        return Ok((
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "Failed to generate image",
+        )
+            .into_response());
+    }
+
+    Ok((
+        [
+            (CONTENT_TYPE, "image/png"),
+            (CACHE_CONTROL, "public, max-age=86400"),
+        ],
+        png_bytes.clone(),
+    )
+        .into_response())
+}
diff --git a/crates/weaver-common/src/agent.rs b/crates/weaver-common/src/agent.rs
index ab94a2f..7fa43c5 100644
--- a/crates/weaver-common/src/agent.rs
+++ b/crates/weaver-common/src/agent.rs
@@ -13,7 +13,7 @@ use jacquard::client::{AgentError, AgentErrorKind, AgentSession, AgentSessionExt
 use jacquard::error::ClientError;
 use jacquard::prelude::*;
 use jacquard::types::blob::{BlobRef, MimeType};
-use jacquard::types::string::{AtUri, Did, RecordKey};
+use jacquard::types::string::{AtUri, Did, RecordKey, Rkey};
 use jacquard::types::tid::Tid;
 use jacquard::types::uri::Uri;
 use jacquard::url::Url;
@@ -79,7 +79,7 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync {
         &'a self,
         blob: Bytes,
         url_path: &'a str,
-        prev: Option,
+        rkey: Option>>,
     ) -> impl Future, PublishedBlob<'a>), WeaverError>> + 'a {
         async move {
             let mime_type =
@@ -90,9 +90,15 @@ pub trait WeaverExt: AgentSessionExt + XrpcExt + Send + Sync {
                 .path(url_path)
                 .upload(BlobRef::Blob(blob))
                 .build();
-            let tid = W_TICKER.lock().await.next(prev);
+            let record_key = match rkey {
+                Some(key) => key,
+                None => {
+                    let tid = W_TICKER.lock().await.next(None);
+                    RecordKey(Rkey::new_owned(tid.as_str())?)
+                }
+            };
             let record = self
-                .create_record(publish_record.clone(), Some(RecordKey::any(tid.as_str())?))
+                .create_record(publish_record.clone(), Some(record_key))
                 .await?;
             let strong_ref = StrongRef::new().uri(record.uri).cid(record.cid).build();
 
diff --git a/crates/weaver-renderer/src/atproto/client.rs b/crates/weaver-renderer/src/atproto/client.rs
index fcef0b6..3068558 100644
--- a/crates/weaver-renderer/src/atproto/client.rs
+++ b/crates/weaver-renderer/src/atproto/client.rs
@@ -104,15 +104,47 @@ impl EmbedResolver for DefaultEmbedResolver<
 }
 
 impl EmbedResolver for () {
-    async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result {
+    async fn resolve_profile(&self, _uri: &AtUri<'_>) -> Result {
         Ok("".to_string())
     }
 
-    async fn resolve_post(&self, uri: &AtUri<'_>) -> Result {
+    async fn resolve_post(&self, _uri: &AtUri<'_>) -> Result {
+        Ok("".to_string())
+    }
+
+    async fn resolve_markdown(
+        &self,
+        _url: &str,
+        _depth: usize,
+    ) -> Result {
         Ok("".to_string())
     }
+}
+
+impl EmbedResolver for ResolvedContent {
+    async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result {
+        self.get_embed_content(uri)
+            .map(|s| s.to_string())
+            .ok_or_else(|| ClientRenderError::EntryFetch {
+                uri: uri.to_string(),
+                source: "Not in pre-resolved content".into(),
+            })
+    }
+
+    async fn resolve_post(&self, uri: &AtUri<'_>) -> Result {
+        self.get_embed_content(uri)
+            .map(|s| s.to_string())
+            .ok_or_else(|| ClientRenderError::EntryFetch {
+                uri: uri.to_string(),
+                source: "Not in pre-resolved content".into(),
+            })
+    }
 
-    async fn resolve_markdown(&self, url: &str, depth: usize) -> Result {
+    async fn resolve_markdown(
+        &self,
+        _url: &str,
+        _depth: usize,
+    ) -> Result {
         Ok("".to_string())
     }
 }
@@ -159,8 +191,22 @@ impl<'a, R: EmbedResolver> ClientContext<'a, R> {
         }
     }
 
+    /// Add an entry index for wikilink resolution
+    pub fn with_entry_index(mut self, index: EntryIndex) -> Self {
+        self.entry_index = Some(index);
+        self
+    }
+
+    /// Add pre-resolved content for sync rendering
+    pub fn with_resolved_content(mut self, content: ResolvedContent) -> Self {
+        self.resolved_content = Some(content);
+        self
+    }
+}
+
+impl<'a> ClientContext<'a> {
     /// Add an embed resolver for fetching embed content
-    pub fn with_embed_resolver(self, resolver: Arc) -> ClientContext<'a, R> {
+    pub fn with_embed_resolver(self, resolver: Arc) -> ClientContext<'a, R> {
         ClientContext {
             entry: self.entry,
             creator_did: self.creator_did,
@@ -173,18 +219,6 @@ impl<'a, R: EmbedResolver> ClientContext<'a, R> {
             title: self.title,
         }
     }
-
-    /// Add an entry index for wikilink resolution
-    pub fn with_entry_index(mut self, index: EntryIndex) -> Self {
-        self.entry_index = Some(index);
-        self
-    }
-
-    /// Add pre-resolved content for sync rendering
-    pub fn with_resolved_content(mut self, content: ResolvedContent) -> Self {
-        self.resolved_content = Some(content);
-        self
-    }
 }
 
 impl<'a, R: EmbedResolver> ClientContext<'a, R> {
diff --git a/crates/weaver-renderer/src/atproto/embed_renderer.rs b/crates/weaver-renderer/src/atproto/embed_renderer.rs
index df17043..b4a14ff 100644
--- a/crates/weaver-renderer/src/atproto/embed_renderer.rs
+++ b/crates/weaver-renderer/src/atproto/embed_renderer.rs
@@ -75,10 +75,9 @@ where
     A: AgentSessionExt,
 {
     // Use GetPosts for richer data (author info, engagement counts)
-    let response = agent
-        .send(GetPosts::new().uris(vec![uri.clone()]).build())
-        .await
-        .map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?;
+    let request = GetPosts::new().uris(vec![uri.clone()]).build();
+    let response = agent.send(request).await;
+    let response = response.map_err(|e| AtProtoPreprocessError::FetchFailed(e.to_string()))?;
 
     let output = response
         .into_output()
@@ -123,10 +122,10 @@ pub async fn fetch_and_render_entry(
 where
     A: AgentSessionExt,
 {
-    use weaver_common::agent::WeaverExt;
-    use markdown_weaver::Parser;
     use crate::atproto::writer::ClientWriter;
     use crate::default_md_options;
+    use markdown_weaver::Parser;
+    use weaver_common::agent::WeaverExt;
 
     // Get rkey from URI
     let rkey = uri
@@ -144,7 +143,9 @@ where
     let mut content_html = String::new();
     ClientWriter::<_, _, ()>::new(parser, &mut content_html)
         .run()
-        .map_err(|e| AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {}", e)))?;
+        .map_err(|e| {
+            AtProtoPreprocessError::FetchFailed(format!("Markdown render failed: {}", e))
+        })?;
 
     // Generate unique ID for the toggle checkbox
     let toggle_id = format!("entry-toggle-{}", rkey.as_ref());
@@ -212,7 +213,10 @@ where
     let collection = uri.collection().map(|c| c.as_ref());
 
     match collection {
-        Some("app.bsky.feed.post") => fetch_and_render_post(uri, agent).await,
+        Some("app.bsky.feed.post") => {
+            let result = fetch_and_render_post(uri, agent).await;
+            result
+        }
         Some("app.bsky.actor.profile") => {
             // Extract DID from URI authority
             fetch_and_render_profile(uri.authority(), agent).await
@@ -224,14 +228,18 @@ where
 }
 
 /// Render a profile from ProfileDataViewInner (weaver, bsky, or tangled)
-fn render_profile_data_view(inner: &ProfileDataViewInner<'_>) -> Result {
+fn render_profile_data_view(
+    inner: &ProfileDataViewInner<'_>,
+) -> Result {
     let mut html = String::new();
 
     match inner {
         ProfileDataViewInner::ProfileView(profile) => {
             // Weaver profile - link to bsky for now
             let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref());
-            html.push_str("");
+            html.push_str(
+                "",
+            );
 
             // Background link covers whole card
             html.push_str(") -> Result {
             // Bsky profile
             let profile_url = format!("https://bsky.app/profile/{}", profile.handle.as_ref());
-            html.push_str("");
+            html.push_str(
+                "",
+            );
 
             // Background link covers whole card
             html.push_str(") -> Result {
             // Tangled profile - link to tangled
             let profile_url = format!("https://tangled.sh/@{}", profile.handle.as_ref());
-            html.push_str("");
+            html.push_str(
+                "",
+            );
 
             // Background link covers whole card
             html.push_str(") -> Result {
             // Unknown - no link, just render
-            html.push_str("");
+            html.push_str(
+                "",
+            );
             html.push_str(&render_generic_data(data));
             html.push_str("");
         }
@@ -435,17 +449,22 @@ fn render_generic_record(
 
     html.push_str("");
 
-    // Show record type as header
+    // Show record type as header (full NSID)
     if let Some(collection) = uri.collection() {
-        // Extract just the record name (e.g., "entry" from "sh.weaver.notebook.entry")
-        let type_name = collection.as_ref().rsplit('.').next().unwrap_or(collection.as_ref());
         html.push_str("");
-        html.push_str(&html_escape(type_name));
+        html.push_str(&html_escape(collection.as_ref()));
         html.push_str("");
     }
 
     // Priority fields to show first (in order)
-    let priority_fields = ["name", "displayName", "title", "text", "description", "content"];
+    let priority_fields = [
+        "name",
+        "displayName",
+        "title",
+        "text",
+        "description",
+        "content",
+    ];
     let mut shown_fields = Vec::new();
 
     if let Some(obj) = data.as_object() {
@@ -524,7 +543,10 @@ pub fn render_author_block(author: &ProfileViewBasic<'_>, link_to_profile: bool)
 }
 
 /// Render author block from ProfileView (has same fields as ProfileViewBasic)
-pub fn render_author_block_full(author: &weaver_api::app_bsky::actor::ProfileView<'_>, link_to_profile: bool) -> String {
+pub fn render_author_block_full(
+    author: &weaver_api::app_bsky::actor::ProfileView<'_>,
+    link_to_profile: bool,
+) -> String {
     render_author_block_inner(
         author.avatar.as_ref().map(|u| u.as_ref()),
         author.display_name.as_ref().map(|s| s.as_ref()),
@@ -704,10 +726,9 @@ pub fn render_quoted_record(record: &ViewRecord<'_>) -> String {
         }
         Some("app.bsky.feed.generator") => {
             // Custom feed - show feed info with type label
-            if let Ok(generator) =
-                jacquard::from_data::(
-                    &record.value,
-                )
+            if let Ok(generator) = jacquard::from_data::<
+                weaver_api::app_bsky::feed::generator::Generator,
+            >(&record.value)
             {
                 html.push_str("Custom Feed");
                 html.push_str("");
@@ -740,7 +761,10 @@ pub fn render_quoted_record(record: &ViewRecord<'_>) -> String {
         }
         Some("app.bsky.graph.starterpack") => {
             // Starter pack
-            if let Ok(sp) = jacquard::from_data::(&record.value) {
+            if let Ok(sp) = jacquard::from_data::<
+                weaver_api::app_bsky::graph::starterpack::Starterpack,
+            >(&record.value)
+            {
                 html.push_str("Starter Pack");
                 html.push_str("");
                 html.push_str(&html_escape(sp.name.as_ref()));
@@ -773,7 +797,9 @@ pub fn render_quoted_record(record: &ViewRecord<'_>) -> String {
 }
 
 /// Render an embed item from a ViewRecord (nested embeds in quotes)
-fn render_view_record_embed(embed: &weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem<'_>) -> String {
+fn render_view_record_embed(
+    embed: &weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem<'_>,
+) -> String {
     use weaver_api::app_bsky::embed::record::ViewRecordEmbedsItem;
 
     match embed {
@@ -995,7 +1021,12 @@ fn render_record_embed(record: &ViewUnionRecord<'_>) -> String {
             html.push_str(""); // end author
 
             // Description
-            if let Some(desc) = sp.record.query("description").single().and_then(|d| d.as_str()) {
+            if let Some(desc) = sp
+                .record
+                .query("description")
+                .single()
+                .and_then(|d| d.as_str())
+            {
                 html.push_str("");
                 html.push_str(&html_escape(desc));
                 html.push_str("");
@@ -1029,16 +1060,25 @@ fn render_record_embed(record: &ViewUnionRecord<'_>) -> String {
 ///
 /// Used as fallback for Unknown variants of open unions.
 fn render_generic_data(data: &Data<'_>) -> String {
+    render_generic_data_with_depth(data, 0)
+}
+
+/// Render generic data with depth tracking for nested objects
+fn render_generic_data_with_depth(data: &Data<'_>, depth: u8) -> String {
     let mut html = String::new();
 
-    html.push_str("");
+    // Only wrap in card at top level
+    let is_nested = depth > 0;
+    if is_nested {
+        html.push_str("");
+    } else {
+        html.push_str("");
+    }
 
     // Show record type as header if present
     if let Some(record_type) = data.type_discriminator() {
-        // Extract just the record name from full NSID (e.g., "app.bsky.feed.post" -> "post")
-        let type_name = record_type.rsplit('.').next().unwrap_or(record_type);
         html.push_str("");
-        html.push_str(&html_escape(type_name));
+        html.push_str(&html_escape(record_type));
         html.push_str("");
     }
 
@@ -1067,11 +1107,13 @@ fn render_generic_data(data: &Data<'_>) -> String {
         }
 
         // Show remaining fields as a simple list
-        html.push_str("");
+        if !is_nested {
+            html.push_str("");
+        }
         for (key, value) in obj.iter() {
             let key_str: &str = key.as_ref();
 
-            // Skip already shown, internal fields, and complex nested objects
+            // Skip already shown, internal fields
             if shown_fields.contains(&key_str)
                 || key_str.starts_with('$')
                 || key_str == "facets"
@@ -1080,7 +1122,7 @@ fn render_generic_data(data: &Data<'_>) -> String {
                 continue;
             }
 
-            if let Some(formatted) = format_field_value(key_str, value) {
+            if let Some(formatted) = format_field_value_with_depth(key_str, value, depth) {
                 html.push_str("");
                 html.push_str("");
                 html.push_str(&html_escape(&format_field_name(key_str)));
@@ -1089,7 +1131,9 @@ fn render_generic_data(data: &Data<'_>) -> String {
                 html.push_str("");
             }
         }
-        html.push_str("");
+        if !is_nested {
+            html.push_str("");
+        }
     }
 
     html.push_str("");
@@ -1114,6 +1158,14 @@ fn format_field_name(name: &str) -> String {
 
 /// Format a field value for display, returning None for complex/unrenderable values
 fn format_field_value(key: &str, value: &Data<'_>) -> Option {
+    format_field_value_with_depth(key, value, 0)
+}
+
+/// Maximum nesting depth for rendering nested objects
+const MAX_NESTED_DEPTH: u8 = 2;
+
+/// Format a field value for display with depth tracking
+fn format_field_value_with_depth(key: &str, value: &Data<'_>, depth: u8) -> Option {
     // String values - detect AT Protocol types
     if let Some(s) = value.as_str() {
         return Some(format_string_value(key, s));
@@ -1126,21 +1178,100 @@ fn format_field_value(key: &str, value: &Data<'_>) -> Option {
 
     // Booleans
     if let Some(b) = value.as_boolean() {
-        let class = if b { "embed-field-bool-true" } else { "embed-field-bool-false" };
-        return Some(format!("{}", class, if b { "yes" } else { "no" }));
+        let class = if b {
+            "embed-field-bool-true"
+        } else {
+            "embed-field-bool-false"
+        };
+        return Some(format!(
+            "{}",
+            class,
+            if b { "yes" } else { "no" }
+        ));
     }
 
-    // Arrays - show count
+    // Arrays - show count or render items if simple
     if let Some(arr) = value.as_array() {
-        let len = arr.len();
-        return Some(format!(
-            "{} item{}",
-            len,
-            if len == 1 { "" } else { "s" }
-        ));
+        return Some(format_array_value(arr, depth));
+    }
+
+    // Nested objects - render if within depth limit
+    if value.as_object().is_some() {
+        if depth < MAX_NESTED_DEPTH {
+            return Some(render_generic_data_with_depth(value, depth + 1));
+        } else {
+            // At max depth, just show field count
+            let count = value.as_object().map(|o| o.len()).unwrap_or(0);
+            return Some(format!(
+                "{} field{}",
+                count,
+                if count == 1 { "" } else { "s" }
+            ));
+        }
+    }
+
+    None
+}
+
+/// Format an array value, rendering items if simple enough
+fn format_array_value(arr: &jacquard::Array<'_>, depth: u8) -> String {
+    let len = arr.len();
+
+    // Empty array
+    if len == 0 {
+        return "empty".to_string();
+    }
+
+    // For small arrays of simple values, show them inline
+    if len <= 3 && depth < MAX_NESTED_DEPTH {
+        let mut items = Vec::new();
+        let mut all_simple = true;
+
+        for item in arr.iter() {
+            if let Some(formatted) = format_simple_value(item) {
+                items.push(formatted);
+            } else {
+                all_simple = false;
+                break;
+            }
+        }
+
+        if all_simple {
+            return format!(
+                "[{}]",
+                items.join(", ")
+            );
+        }
+    }
+
+    // Otherwise just show count
+    format!(
+        "{} item{}",
+        len,
+        if len == 1 { "" } else { "s" }
+    )
+}
+
+/// Format a simple value (string, number, bool) without field name context
+fn format_simple_value(value: &Data<'_>) -> Option {
+    if let Some(s) = value.as_str() {
+        // Keep it short for array display
+        let display = if s.len() > 50 {
+            format!("{}…", &s[..50])
+        } else {
+            s.to_string()
+        };
+        return Some(format!("\"{}\"", html_escape(&display)));
+    }
+
+    if let Some(n) = value.as_integer() {
+        return Some(n.to_string());
+    }
+
+    if let Some(b) = value.as_boolean() {
+        return Some(if b { "true" } else { "false" }.to_string());
     }
 
-    // Skip nested objects - too complex for inline display
     None
 }
 
@@ -1173,17 +1304,31 @@ fn format_string_value(key: &str, s: &str) -> String {
     // Datetime fields - show just the date
     if key.ends_with("At") || key == "createdAt" || key == "indexedAt" {
         let date_part = s.split('T').next().unwrap_or(s);
-        return format!("{}", html_escape(date_part));
+        return format!(
+            "{}",
+            html_escape(date_part)
+        );
     }
 
     // NSID (e.g., app.bsky.feed.post)
-    if s.contains('.') && s.chars().all(|c| c.is_alphanumeric() || c == '.') && s.matches('.').count() >= 2 {
+    if s.contains('.')
+        && s.chars().all(|c| c.is_alphanumeric() || c == '.')
+        && s.matches('.').count() >= 2
+    {
         return format!("{}", html_escape(s));
     }
 
     // Handle (contains dots, no colons or slashes)
-    if s.contains('.') && !s.contains(':') && !s.contains('/') && s.chars().all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_') {
-        return format!("@{}", html_escape(s));
+    if s.contains('.')
+        && !s.contains(':')
+        && !s.contains('/')
+        && s.chars()
+            .all(|c| c.is_alphanumeric() || c == '.' || c == '-' || c == '_')
+    {
+        return format!(
+            "@{}",
+            html_escape(s)
+        );
     }
 
     // Plain string
@@ -1197,15 +1342,24 @@ fn format_aturi_display(uri: &str) -> String {
         let mut result = String::from("at://");
 
         if !parts.is_empty() {
-            result.push_str(&format!("{}", html_escape(parts[0])));
+            result.push_str(&format!(
+                "{}",
+                html_escape(parts[0])
+            ));
         }
         if parts.len() > 1 {
             result.push_str("/");
-            result.push_str(&format!("{}", html_escape(parts[1])));
+            result.push_str(&format!(
+                "{}",
+                html_escape(parts[1])
+            ));
         }
         if parts.len() > 2 {
             result.push_str("/");
-            result.push_str(&format!("{}", html_escape(parts[2])));
+            result.push_str(&format!(
+                "{}",
+                html_escape(parts[2])
+            ));
         }
         result
     } else {
@@ -1229,7 +1383,10 @@ fn format_did_display(did: &str) -> String {
             );
         }
     }
-    format!("{}", html_escape(did))
+    format!(
+        "{}",
+        html_escape(did)
+    )
 }
 
 // =============================================================================
diff --git a/crates/weaver-renderer/src/atproto/writer.rs b/crates/weaver-renderer/src/atproto/writer.rs
index 054dcb3..c2c29f7 100644
--- a/crates/weaver-renderer/src/atproto/writer.rs
+++ b/crates/weaver-renderer/src/atproto/writer.rs
@@ -3,11 +3,13 @@
 //! Similar to StaticPageWriter but designed for client-side use with
 //! synchronous embed content injection.
 
+use jacquard::types::string::AtUri;
 use markdown_weaver::{
     Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
 };
 use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text};
 use std::collections::HashMap;
+use weaver_common::ResolvedContent;
 
 /// Synchronous callback for injecting embed content
 ///
@@ -22,6 +24,32 @@ impl EmbedContentProvider for () {
     }
 }
 
+impl EmbedContentProvider for ResolvedContent {
+    fn get_embed_content(&self, tag: &Tag<'_>) -> Option {
+        let url = match tag {
+            Tag::Embed { dest_url, .. } => Some(dest_url.as_ref()),
+            // WikiLink images with at:// URLs are embeds in disguise
+            Tag::Image {
+                link_type: LinkType::WikiLink { .. },
+                dest_url,
+                ..
+            } if dest_url.starts_with("at://") || dest_url.starts_with("did:") => {
+                Some(dest_url.as_ref())
+            }
+            _ => None,
+        };
+
+        if let Some(url) = url {
+            if url.starts_with("at://") {
+                if let Ok(at_uri) = AtUri::new(url) {
+                    return self.get_embed_content(&at_uri).map(|s| s.to_string());
+                }
+            }
+        }
+        None
+    }
+}
+
 /// Simple writer that outputs HTML from markdown events
 ///
 /// This writer is designed for client-side rendering where embeds may have
@@ -50,6 +78,28 @@ enum TableState {
     Body,
 }
 
+impl<'a, I: Iterator>, W: StrWrite> ClientWriter<'a, I, W> {
+    /// Add an embed content provider
+    pub fn with_embed_provider(
+        self,
+        provider: E,
+    ) -> ClientWriter<'a, I, W, E> {
+        ClientWriter {
+            events: self.events,
+            writer: self.writer,
+            end_newline: self.end_newline,
+            in_non_writing_block: self.in_non_writing_block,
+            table_state: self.table_state,
+            table_alignments: self.table_alignments,
+            table_cell_index: self.table_cell_index,
+            numbers: self.numbers,
+            embed_provider: Some(provider),
+            code_buffer: self.code_buffer,
+            _phantom: std::marker::PhantomData,
+        }
+    }
+}
+
 impl<'a, I: Iterator>, W: StrWrite, E: EmbedContentProvider>
     ClientWriter<'a, I, W, E>
 {
@@ -69,22 +119,6 @@ impl<'a, I: Iterator>, W: StrWrite, E: EmbedContentProvider>
         }
     }
 
-    /// Add an embed content provider
-    pub fn with_embed_provider(self, provider: E) -> ClientWriter<'a, I, W, E> {
-        ClientWriter {
-            events: self.events,
-            writer: self.writer,
-            end_newline: self.end_newline,
-            in_non_writing_block: self.in_non_writing_block,
-            table_state: self.table_state,
-            table_alignments: self.table_alignments,
-            table_cell_index: self.table_cell_index,
-            numbers: self.numbers,
-            embed_provider: Some(provider),
-            code_buffer: self.code_buffer,
-            _phantom: std::marker::PhantomData,
-        }
-    }
     #[inline]
     fn write_newline(&mut self) -> Result<(), W::Error> {
         self.end_newline = true;
@@ -108,6 +142,25 @@ impl<'a, I: Iterator>, W: StrWrite, E: EmbedContentProvider>
         Ok(self.writer)
     }
 
+    /// Consume events until End tag without writing anything.
+    /// Used when we've already rendered content and just need to advance the iterator.
+    fn consume_until_end(&mut self) {
+        use Event::*;
+        let mut nest = 0;
+        while let Some(event) = self.events.next() {
+            match event {
+                Start(_) => nest += 1,
+                End(_) => {
+                    if nest == 0 {
+                        break;
+                    }
+                    nest -= 1;
+                }
+                _ => {}
+            }
+        }
+    }
+
     // Consume raw text events until end tag, for alt attributes
     fn raw_text(&mut self) -> Result<(), W::Error> {
         use Event::*;
@@ -173,30 +226,26 @@ impl<'a, I: Iterator>, W: StrWrite, E: EmbedContentProvider>
                 escape_html_body_text(&mut self.writer, &text)?;
                 self.write("")?;
             }
-            InlineMath(text) => {
-                match crate::math::render_math(&text, false) {
-                    crate::math::MathResult::Success(mathml) => {
-                        self.write(r#""#)?;
-                        self.write(&mathml)?;
-                        self.write("")?;
-                    }
-                    crate::math::MathResult::Error { html, .. } => {
-                        self.write(&html)?;
-                    }
+            InlineMath(text) => match crate::math::render_math(&text, false) {
+                crate::math::MathResult::Success(mathml) => {
+                    self.write(r#""#)?;
+                    self.write(&mathml)?;
+                    self.write("")?;
                 }
-            }
-            DisplayMath(text) => {
-                match crate::math::render_math(&text, true) {
-                    crate::math::MathResult::Success(mathml) => {
-                        self.write(r#""#)?;
-                        self.write(&mathml)?;
-                        self.write("")?;
-                    }
-                    crate::math::MathResult::Error { html, .. } => {
-                        self.write(&html)?;
-                    }
+                crate::math::MathResult::Error { html, .. } => {
+                    self.write(&html)?;
                 }
-            }
+            },
+            DisplayMath(text) => match crate::math::render_math(&text, true) {
+                crate::math::MathResult::Success(mathml) => {
+                    self.write(r#""#)?;
+                    self.write(&mathml)?;
+                    self.write("")?;
+                }
+                crate::math::MathResult::Error { html, .. } => {
+                    self.write(&html)?;
+                }
+            },
             Html(html) | InlineHtml(html) => {
                 self.write(&html)?;
             }
@@ -424,12 +473,42 @@ impl<'a, I: Iterator>, W: StrWrite, E: EmbedContentProvider>
                 }
                 self.write("\">")
             }
-            Tag::Image {
-                dest_url,
-                title,
-                attrs,
+            ref tag @ Tag::Image {
+                ref dest_url,
+                ref title,
+                ref attrs,
+                ref link_type,
                 ..
             } => {
+                // Check if this is an AT embed disguised as a WikiLink image
+                // (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type)
+                if matches!(link_type, LinkType::WikiLink { .. })
+                    && (dest_url.starts_with("at://") || dest_url.starts_with("did:"))
+                {
+                    tracing::debug!("[ClientWriter] AT embed image detected: {}", dest_url);
+                    if let Some(embed_provider) = &self.embed_provider {
+                        if let Some(html) = embed_provider.get_embed_content(&tag) {
+                            tracing::debug!("[ClientWriter] Got embed content for {}", dest_url);
+                            // Consume events without writing - we're replacing with embed HTML
+                            self.consume_until_end();
+                            return self.write(&html);
+                        } else {
+                            tracing::debug!("[ClientWriter] No embed content from provider for {}", dest_url);
+                        }
+                    } else {
+                        tracing::debug!("[ClientWriter] No embed provider available");
+                    }
+                    // Fallback: render as link if no embed content available
+                    tracing::debug!("[ClientWriter] Using fallback link for {}", dest_url);
+                    self.consume_until_end();
+                    self.write("")?;
+                    escape_html(&mut self.writer, &dest_url)?;
+                    return self.write("");
+                }
+
+                // Regular image handling
                 self.write("\"")?; .embed-author-handle {{
+    display: block;
+    margin-bottom: 0.25rem;
+}}
+
 .embed-field-name {{
     color: var(--color-subtle);
 }}
@@ -748,7 +763,6 @@ a.embed-author-name:hover {{
 .atproto-record > .embed-author-handle {{
     display: block;
     margin-bottom: 0.25rem;
-    text-transform: capitalize;
 }}
 
 .atproto-record > .embed-author-name {{
diff --git a/crates/weaver-renderer/src/utils.rs b/crates/weaver-renderer/src/utils.rs
index f3a35b0..72fcbbd 100644
--- a/crates/weaver-renderer/src/utils.rs
+++ b/crates/weaver-renderer/src/utils.rs
@@ -1,7 +1,7 @@
-use std::{path::Path, sync::OnceLock};
 use markdown_weaver::{CodeBlockKind, CowStr, Event, Tag};
 use miette::IntoDiagnostic;
 use n0_future::TryFutureExt;
+use std::{path::Path, sync::OnceLock};
 
 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
 use regex::Regex;
@@ -117,121 +117,6 @@ pub fn flatten_dir_to_just_one_parent(path: &str) -> (&str, &str) {
     ("", path)
 }
 
-fn event_to_owned<'a>(event: Event<'a>) -> Event<'a> {
-    match event {
-        Event::Start(tag) => Event::Start(tag_to_owned(tag)),
-        Event::End(tag) => Event::End(tag),
-        Event::Text(cowstr) => Event::Text(CowStr::from(cowstr.into_string())),
-        Event::Code(cowstr) => Event::Code(CowStr::from(cowstr.into_string())),
-        Event::Html(cowstr) => Event::Html(CowStr::from(cowstr.into_string())),
-        Event::InlineHtml(cowstr) => Event::InlineHtml(CowStr::from(cowstr.into_string())),
-        Event::FootnoteReference(cowstr) => {
-            Event::FootnoteReference(CowStr::from(cowstr.into_string()))
-        }
-        Event::SoftBreak => Event::SoftBreak,
-        Event::HardBreak => Event::HardBreak,
-        Event::Rule => Event::Rule,
-        Event::TaskListMarker(checked) => Event::TaskListMarker(checked),
-        Event::InlineMath(cowstr) => Event::InlineMath(CowStr::from(cowstr.into_string())),
-        Event::DisplayMath(cowstr) => Event::DisplayMath(CowStr::from(cowstr.into_string())),
-        Event::WeaverBlock(cow_str) => todo!(),
-    }
-}
-
-fn tag_to_owned<'a>(tag: Tag<'a>) -> Tag<'a> {
-    match tag {
-        Tag::Paragraph => Tag::Paragraph,
-        Tag::Heading {
-            level: heading_level,
-            id,
-            classes,
-            attrs,
-        } => Tag::Heading {
-            level: heading_level,
-            id: id.map(|cowstr| CowStr::from(cowstr.into_string())),
-            classes: classes
-                .into_iter()
-                .map(|cowstr| CowStr::from(cowstr.into_string()))
-                .collect(),
-            attrs: attrs
-                .into_iter()
-                .map(|(attr, value)| {
-                    (
-                        CowStr::from(attr.into_string()),
-                        value.map(|cowstr| CowStr::from(cowstr.into_string())),
-                    )
-                })
-                .collect(),
-        },
-        Tag::BlockQuote(blockquote_kind) => Tag::BlockQuote(blockquote_kind),
-        Tag::CodeBlock(codeblock_kind) => Tag::CodeBlock(codeblock_kind_to_owned(codeblock_kind)),
-        Tag::List(optional) => Tag::List(optional),
-        Tag::Item => Tag::Item,
-        Tag::FootnoteDefinition(cowstr) => {
-            Tag::FootnoteDefinition(CowStr::from(cowstr.into_string()))
-        }
-        Tag::Table(alignment_vector) => Tag::Table(alignment_vector),
-        Tag::TableHead => Tag::TableHead,
-        Tag::TableRow => Tag::TableRow,
-        Tag::TableCell => Tag::TableCell,
-        Tag::Emphasis => Tag::Emphasis,
-        Tag::Strong => Tag::Strong,
-        Tag::Strikethrough => Tag::Strikethrough,
-        Tag::Link {
-            link_type,
-            dest_url,
-            title,
-            id,
-        } => Tag::Link {
-            link_type,
-            dest_url: CowStr::from(dest_url.into_string()),
-            title: CowStr::from(title.into_string()),
-            id: CowStr::from(id.into_string()),
-        },
-        Tag::Embed {
-            embed_type,
-            dest_url,
-            title,
-            id,
-            attrs,
-        } => Tag::Embed {
-            embed_type,
-            dest_url: CowStr::from(dest_url.into_string()),
-            title: CowStr::from(title.into_string()),
-            id: CowStr::from(id.into_string()),
-            attrs,
-        },
-        Tag::Image {
-            link_type,
-            dest_url,
-            title,
-            id,
-            attrs,
-        } => Tag::Image {
-            link_type,
-            dest_url: CowStr::from(dest_url.into_string()),
-            title: CowStr::from(title.into_string()),
-            id: CowStr::from(id.into_string()),
-            attrs,
-        },
-        Tag::HtmlBlock => Tag::HtmlBlock,
-        Tag::MetadataBlock(metadata_block_kind) => Tag::MetadataBlock(metadata_block_kind),
-        Tag::DefinitionList => Tag::DefinitionList,
-        Tag::DefinitionListTitle => Tag::DefinitionListTitle,
-        Tag::DefinitionListDefinition => Tag::DefinitionListDefinition,
-        Tag::Superscript => todo!(),
-        Tag::Subscript => todo!(),
-        Tag::WeaverBlock(weaver_block_kind, weaver_attributes) => todo!(),
-    }
-}
-
-fn codeblock_kind_to_owned<'a>(codeblock_kind: CodeBlockKind<'_>) -> CodeBlockKind<'a> {
-    match codeblock_kind {
-        CodeBlockKind::Indented => CodeBlockKind::Indented,
-        CodeBlockKind::Fenced(cowstr) => CodeBlockKind::Fenced(CowStr::from(cowstr.into_string())),
-    }
-}
-
 #[cfg(not(all(target_family = "wasm", target_os = "unknown")))]
 use tokio::fs::{self, File};
 
@@ -253,7 +138,6 @@ pub async fn create_file(dest: &Path) -> miette::Result {
     Ok(file)
 }
 
-
 /// Path lookup in an Obsidian vault
 ///
 /// Credit to https://github.com/zoni