···59/// - `initial_content`: Optional initial markdown content (for new entries)
60/// - `entry_uri`: Optional AT-URI of an existing entry to edit
61/// - `target_notebook`: Optional notebook title to add the entry to when publishing
062#[component]
63pub fn MarkdownEditor(
64 initial_content: Option<String>,
65 entry_uri: Option<String>,
66 target_notebook: Option<SmolStr>,
067) -> Element {
68 let fetcher = use_context::<Fetcher>();
6970- // Determine draft key - use entry URI if editing existing, otherwise generate TID
71 let draft_key = use_hook(|| {
72 entry_uri.clone().unwrap_or_else(|| {
73 format!(
···77 })
78 });
7980- // Parse entry URI once
81 let parsed_uri = entry_uri.as_ref().and_then(|s| {
82 jacquard::types::string::AtUri::new(s)
83 .ok()
84 .map(|u| u.into_static())
85 });
86-87- // Clone draft_key for render (resource closure moves it)
88 let draft_key_for_render = draft_key.clone();
08990- // Resource loads and merges document state
91 let load_resource = use_resource(move || {
92 let fetcher = fetcher.clone();
93 let draft_key = draft_key.clone();
···95 let initial_content = initial_content.clone();
9697 async move {
98- // Try to load merged state from PDS + localStorage
99 match load_and_merge_document(&fetcher, &draft_key, entry_uri.as_ref()).await {
100 Ok(Some(state)) => {
101 tracing::debug!("Loaded merged document state");
···110 let is_own_entry = match entry_authority {
111 AtIdentifier::Did(did) => did == ¤t_did,
112 AtIdentifier::Handle(handle) => {
113- // Resolve handle to DID and compare
114 match fetcher.client.resolve_handle(handle).await {
115 Ok(resolved_did) => resolved_did == current_did,
116 Err(_) => false,
···127 );
128 }
129 }
130-131- // Try to load the entry content from PDS
132 match load_entry_for_editing(&fetcher, uri).await {
133 Ok(loaded) => {
134 // Create LoadedDocState from entry
···188 }
189 });
190191- // Render based on load state
192 match &*load_resource.read() {
193 Some(LoadResult::Loaded(state)) => {
194 rsx! {
···196 key: "{draft_key_for_render}",
197 draft_key: draft_key_for_render.clone(),
198 loaded_state: state.clone(),
00199 }
200 }
201 }
···226/// - PDS sync with auto-save
227/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic)
228#[component]
229-fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Element {
000000230 // Context for authenticated API calls
231 let fetcher = use_context::<Fetcher>();
232 let auth_state = use_context::<Signal<AuthState>>();
233234- // Create EditorDocument from loaded state (must be in use_hook for Signals)
235 let mut document = use_hook(|| {
236 let doc = EditorDocument::from_loaded_state(loaded_state.clone());
237- // Save to localStorage so we have a local backup
238 storage::save_to_storage(&doc, &draft_key).ok();
239 doc
240 });
241 let editor_id = "markdown-editor";
242-243- // Cache for incremental paragraph rendering
244 let mut render_cache = use_signal(|| render::RenderCache::default());
245-246- // Image resolver for mapping /image/{name} to data URLs or CDN URLs
247 let mut image_resolver = use_signal(EditorImageResolver::default);
0248249- // Render paragraphs with incremental caching
250- // Reads document.last_edit signal - creates dependency on content changes only
251 let doc_for_memo = document.clone();
0252 let paragraphs = use_memo(move || {
253- let edit = doc_for_memo.last_edit(); // Signal read - reactive dependency
254 let cache = render_cache.peek();
255 let resolver = image_resolver();
0256257- let (paras, new_cache) = render::render_paragraphs_incremental(
258 doc_for_memo.loro_text(),
259 Some(&cache),
260 edit.as_ref(),
261 Some(&resolver),
00262 );
263-264- // Update cache for next render (write-only via spawn to avoid reactive loop)
265 dioxus::prelude::spawn(async move {
266 render_cache.set(new_cache);
0267 });
268269 paras
270 });
271272- // Flatten offset maps from all paragraphs
000000000000000000000000000000000000000000000000000000273 let offset_map = use_memo(move || {
274 paragraphs()
275 .iter()
276 .flat_map(|p| p.offset_map.iter().cloned())
277 .collect::<Vec<_>>()
278 });
279-280- // Flatten syntax spans from all paragraphs
281 let syntax_spans = use_memo(move || {
282 paragraphs()
283 .iter()
284 .flat_map(|p| p.syntax_spans.iter().cloned())
285 .collect::<Vec<_>>()
286 });
287-288- // Cache paragraphs for change detection AND for event handlers to access
289 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new());
290291- // Update DOM when paragraphs change (incremental rendering)
292 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
293 let mut doc_for_dom = document.clone();
294 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···365 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
366 let mut interval_holder: Signal<Option<gloo_timers::callback::Interval>> = use_signal(|| None);
367368- // Auto-save with periodic check (no reactive dependency to avoid loops)
369 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
370 let doc_for_autosave = document.clone();
371 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···385 None => true,
386 Some(last) => ¤t_frontiers != last,
387 }
388- }; // drop last_frontiers borrow here
389390 if needs_save {
391 doc.sync_loro_cursor();
···436 // Get target range from the event if available
437 let paras = cached_paras.peek().clone();
438 let target_range = get_target_range_from_event(&evt, editor_id, ¶s);
439-440- // Get data from the event
441 let data = get_data_from_event(&evt);
442-443- // Build context and handle
444 let ctx = BeforeInputContext {
445 input_type: input_type.clone(),
446 data,
···530 closure.forget();
531 });
532533- // Local state for adding new tags
534- let mut new_tag = use_signal(String::new);
535-536 rsx! {
537 Stylesheet { href: asset!("/assets/styling/editor.css") }
538 div { class: "markdown-editor-container",
···621 PublishButton {
622 document: document.clone(),
623 draft_key: draft_key.to_string(),
0624 }
625 }
626 }
···804805 onclick: {
806 let mut doc = document.clone();
807- move |_evt| {
808 tracing::debug!("onclick fired");
809 let paras = cached_paragraphs();
000000000000000000000000000000000000810 sync_cursor_from_dom(&mut doc, editor_id, ¶s);
811 let spans = syntax_spans();
812 let cursor_offset = doc.cursor.read().offset;
···980 }
981 },
982 }
983-984- // Debug panel snug below editor
985 div { class: "editor-debug",
986 div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" },
987 ReportButton {
···991 }
992 }
993994- // Toolbar in grid column 2, row 3
995 EditorToolbar {
996 on_format: {
997 let mut doc = document.clone();
···1040 spawn(async move {
1041 let client = fetcher.get_client();
10420001043 // Upload blob and create temporary PublishedBlob record
1044 match client.publish_blob(data, &name_for_upload, None).await {
1045 Ok((strong_ref, published_blob)) => {
···1061 }
1062 };
10631064- // Build Image using the builder API
01065 let name_for_resolver = name_for_upload.clone();
1066 let image = Image::new()
1067 .alt(alt_for_upload.to_cowstr())
1068 .image(published_blob.upload)
1069 .name(name_for_upload.to_cowstr())
1070 .build();
1071-1072- // Add to document
1073 doc_for_spawn.add_image(&image, Some(&strong_ref.uri));
10741075 // Promote from pending to uploaded in resolver
···1083 });
10841085 tracing::info!(name = %name_for_resolver, "Image uploaded to PDS");
000000000000001086 }
1087 Err(e) => {
1088 tracing::error!(error = %e, "Failed to upload image");
···1091 }
1092 });
1093 } else {
1094- tracing::info!(name = %name, "Image added with data URL (not authenticated)");
1095 }
1096 }
1097 },
···59/// - `initial_content`: Optional initial markdown content (for new entries)
60/// - `entry_uri`: Optional AT-URI of an existing entry to edit
61/// - `target_notebook`: Optional notebook title to add the entry to when publishing
62+/// - `entry_index`: Optional index of entries for wikilink validation
63#[component]
64pub fn MarkdownEditor(
65 initial_content: Option<String>,
66 entry_uri: Option<String>,
67 target_notebook: Option<SmolStr>,
68+ entry_index: Option<weaver_common::EntryIndex>,
69) -> Element {
70 let fetcher = use_context::<Fetcher>();
71072 let draft_key = use_hook(|| {
73 entry_uri.clone().unwrap_or_else(|| {
74 format!(
···78 })
79 });
80081 let parsed_uri = entry_uri.as_ref().and_then(|s| {
82 jacquard::types::string::AtUri::new(s)
83 .ok()
84 .map(|u| u.into_static())
85 });
0086 let draft_key_for_render = draft_key.clone();
87+ let target_notebook_for_render = target_notebook.clone();
88089 let load_resource = use_resource(move || {
90 let fetcher = fetcher.clone();
91 let draft_key = draft_key.clone();
···93 let initial_content = initial_content.clone();
9495 async move {
096 match load_and_merge_document(&fetcher, &draft_key, entry_uri.as_ref()).await {
97 Ok(Some(state)) => {
98 tracing::debug!("Loaded merged document state");
···107 let is_own_entry = match entry_authority {
108 AtIdentifier::Did(did) => did == ¤t_did,
109 AtIdentifier::Handle(handle) => {
0110 match fetcher.client.resolve_handle(handle).await {
111 Ok(resolved_did) => resolved_did == current_did,
112 Err(_) => false,
···123 );
124 }
125 }
00126 match load_entry_for_editing(&fetcher, uri).await {
127 Ok(loaded) => {
128 // Create LoadedDocState from entry
···182 }
183 });
1840185 match &*load_resource.read() {
186 Some(LoadResult::Loaded(state)) => {
187 rsx! {
···189 key: "{draft_key_for_render}",
190 draft_key: draft_key_for_render.clone(),
191 loaded_state: state.clone(),
192+ target_notebook: target_notebook_for_render.clone(),
193+ entry_index: entry_index.clone(),
194 }
195 }
196 }
···221/// - PDS sync with auto-save
222/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic)
223#[component]
224+fn MarkdownEditorInner(
225+ draft_key: String,
226+ loaded_state: LoadedDocState,
227+ target_notebook: Option<SmolStr>,
228+ /// Optional entry index for wikilink validation in the editor
229+ entry_index: Option<weaver_common::EntryIndex>,
230+) -> Element {
231 // Context for authenticated API calls
232 let fetcher = use_context::<Fetcher>();
233 let auth_state = use_context::<Signal<AuthState>>();
2340235 let mut document = use_hook(|| {
236 let doc = EditorDocument::from_loaded_state(loaded_state.clone());
0237 storage::save_to_storage(&doc, &draft_key).ok();
238 doc
239 });
240 let editor_id = "markdown-editor";
00241 let mut render_cache = use_signal(|| render::RenderCache::default());
00242 let mut image_resolver = use_signal(EditorImageResolver::default);
243+ let resolved_content = use_signal(weaver_common::ResolvedContent::default);
24400245 let doc_for_memo = document.clone();
246+ let doc_for_refs = document.clone();
247 let paragraphs = use_memo(move || {
248+ let edit = doc_for_memo.last_edit();
249 let cache = render_cache.peek();
250 let resolver = image_resolver();
251+ let resolved = resolved_content();
252253+ let (paras, new_cache, refs) = render::render_paragraphs_incremental(
254 doc_for_memo.loro_text(),
255 Some(&cache),
256 edit.as_ref(),
257 Some(&resolver),
258+ entry_index.as_ref(),
259+ &resolved,
260 );
261+ let mut doc_for_spawn = doc_for_refs.clone();
0262 dioxus::prelude::spawn(async move {
263 render_cache.set(new_cache);
264+ doc_for_spawn.set_collected_refs(refs);
265 });
266267 paras
268 });
269270+ // Background fetch for AT embeds
271+ let mut resolved_content_for_fetch = resolved_content.clone();
272+ let doc_for_embeds = document.clone();
273+ let fetcher_for_embeds = fetcher.clone();
274+ use_effect(move || {
275+ let refs = doc_for_embeds.collected_refs.read();
276+ let current_resolved = resolved_content_for_fetch.peek();
277+ let fetcher = fetcher_for_embeds.clone();
278+279+ // Find AT embeds that need fetching
280+ let to_fetch: Vec<String> = refs
281+ .iter()
282+ .filter_map(|r| match r {
283+ weaver_common::ExtractedRef::AtEmbed { uri, .. } => {
284+ // Skip if already resolved
285+ if let Ok(at_uri) = jacquard::types::string::AtUri::new(uri) {
286+ if current_resolved.get_embed_content(&at_uri).is_none() {
287+ return Some(uri.clone());
288+ }
289+ }
290+ None
291+ }
292+ _ => None,
293+ })
294+ .collect();
295+296+ if to_fetch.is_empty() {
297+ return;
298+ }
299+300+ // Spawn background fetches
301+ dioxus::prelude::spawn(async move {
302+ for uri_str in to_fetch {
303+ let Ok(at_uri) = jacquard::types::string::AtUri::new(&uri_str) else {
304+ continue;
305+ };
306+307+ match weaver_renderer::atproto::fetch_and_render(&at_uri, &fetcher)
308+ .await
309+ {
310+ Ok(html) => {
311+ resolved_content_for_fetch.with_mut(|rc| {
312+ rc.add_embed(at_uri.into_static(), html, None);
313+ });
314+ }
315+ Err(e) => {
316+ tracing::warn!("failed to fetch embed {}: {}", uri_str, e);
317+ }
318+ }
319+ }
320+ });
321+ });
322+323+ let mut new_tag = use_signal(String::new);
324+325 let offset_map = use_memo(move || {
326 paragraphs()
327 .iter()
328 .flat_map(|p| p.offset_map.iter().cloned())
329 .collect::<Vec<_>>()
330 });
00331 let syntax_spans = use_memo(move || {
332 paragraphs()
333 .iter()
334 .flat_map(|p| p.syntax_spans.iter().cloned())
335 .collect::<Vec<_>>()
336 });
00337 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new());
3380339 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
340 let mut doc_for_dom = document.clone();
341 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···412 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
413 let mut interval_holder: Signal<Option<gloo_timers::callback::Interval>> = use_signal(|| None);
4140415 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
416 let doc_for_autosave = document.clone();
417 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···431 None => true,
432 Some(last) => ¤t_frontiers != last,
433 }
434+ };
435436 if needs_save {
437 doc.sync_loro_cursor();
···482 // Get target range from the event if available
483 let paras = cached_paras.peek().clone();
484 let target_range = get_target_range_from_event(&evt, editor_id, ¶s);
00485 let data = get_data_from_event(&evt);
00486 let ctx = BeforeInputContext {
487 input_type: input_type.clone(),
488 data,
···572 closure.forget();
573 });
574000575 rsx! {
576 Stylesheet { href: asset!("/assets/styling/editor.css") }
577 div { class: "markdown-editor-container",
···660 PublishButton {
661 document: document.clone(),
662 draft_key: draft_key.to_string(),
663+ target_notebook: target_notebook.as_ref().map(|s| s.to_string()),
664 }
665 }
666 }
···844845 onclick: {
846 let mut doc = document.clone();
847+ move |evt| {
848 tracing::debug!("onclick fired");
849 let paras = cached_paragraphs();
850+851+ // Check if click target is a math-clickable element
852+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
853+ {
854+ use dioxus::web::WebEventExt;
855+ use wasm_bindgen::JsCast;
856+857+ let web_evt = evt.as_web_event();
858+ if let Some(target) = web_evt.target() {
859+ if let Some(element) = target.dyn_ref::<web_sys::Element>() {
860+ // Check element or ancestors for math-clickable
861+ if let Ok(Some(math_el)) = element.closest(".math-clickable") {
862+ if let Some(char_target) = math_el.get_attribute("data-char-target") {
863+ if let Ok(offset) = char_target.parse::<usize>() {
864+ tracing::debug!("math-clickable clicked, moving cursor to {}", offset);
865+ doc.cursor.write().offset = offset;
866+ *doc.selection.write() = None;
867+ // Update visibility FIRST so math-source is visible
868+ let spans = syntax_spans();
869+ update_syntax_visibility(offset, None, &spans, ¶s);
870+ // Then set DOM selection
871+ let map = offset_map();
872+ let _ = crate::components::editor::cursor::restore_cursor_position(
873+ offset,
874+ &map,
875+ editor_id,
876+ None,
877+ );
878+ return;
879+ }
880+ }
881+ }
882+ }
883+ }
884+ }
885+886 sync_cursor_from_dom(&mut doc, editor_id, ¶s);
887 let spans = syntax_spans();
888 let cursor_offset = doc.cursor.read().offset;
···1056 }
1057 },
1058 }
001059 div { class: "editor-debug",
1060 div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" },
1061 ReportButton {
···1065 }
1066 }
106701068 EditorToolbar {
1069 on_format: {
1070 let mut doc = document.clone();
···1113 spawn(async move {
1114 let client = fetcher.get_client();
11151116+ // Clone data for cache pre-warming
1117+ let data_for_cache = data.clone();
1118+1119 // Upload blob and create temporary PublishedBlob record
1120 match client.publish_blob(data, &name_for_upload, None).await {
1121 Ok((strong_ref, published_blob)) => {
···1137 }
1138 };
11391140+ let cid = published_blob.upload.blob().cid().clone().into_static();
1141+1142 let name_for_resolver = name_for_upload.clone();
1143 let image = Image::new()
1144 .alt(alt_for_upload.to_cowstr())
1145 .image(published_blob.upload)
1146 .name(name_for_upload.to_cowstr())
1147 .build();
001148 doc_for_spawn.add_image(&image, Some(&strong_ref.uri));
11491150 // Promote from pending to uploaded in resolver
···1158 });
11591160 tracing::info!(name = %name_for_resolver, "Image uploaded to PDS");
1161+1162+ // Pre-warm server cache with blob bytes
1163+ #[cfg(feature = "fullstack-server")]
1164+ {
1165+ use jacquard::smol_str::ToSmolStr;
1166+ if let Err(e) = crate::data::cache_blob_bytes(
1167+ cid.to_smolstr(),
1168+ Some(name_for_resolver.into()),
1169+ None,
1170+ data_for_cache.into(),
1171+ ).await {
1172+ tracing::warn!(error = %e, "Failed to pre-warm blob cache");
1173+ }
1174+ }
1175 }
1176 Err(e) => {
1177 tracing::error!(error = %e, "Failed to upload image");
···1180 }
1181 });
1182 } else {
1183+ tracing::debug!(name = %name, "Image added with data URL (not authenticated)");
1184 }
1185 }
1186 },
+31-3
crates/weaver-app/src/components/editor/cursor.rs
···115///
116/// Walks all text nodes in the container, accumulating their UTF-16 lengths
117/// until we find the node containing the target offset.
0118///
119/// Returns (text_node, offset_within_node).
120#[cfg(all(target_family = "wasm", target_os = "unknown"))]
···127 .document()
128 .ok_or("no document")?;
129130- // Create tree walker to find text nodes
131- // SHOW_TEXT = 4 (from DOM spec)
132- let walker = document.create_tree_walker_with_what_to_show(container, 4)?;
133134 let mut accumulated_utf16 = 0;
135 let mut last_node: Option<web_sys::Node> = None;
0136137 while let Some(node) = walker.next_node()? {
000000000000000000000000000138 last_node = Some(node.clone());
139140 if let Some(text) = node.text_content() {
···115///
116/// Walks all text nodes in the container, accumulating their UTF-16 lengths
117/// until we find the node containing the target offset.
118+/// Skips text nodes inside contenteditable="false" elements (like embeds).
119///
120/// Returns (text_node, offset_within_node).
121#[cfg(all(target_family = "wasm", target_os = "unknown"))]
···128 .document()
129 .ok_or("no document")?;
130131+ // Use SHOW_ALL to see element boundaries for tracking non-editable regions
132+ let walker = document.create_tree_walker_with_what_to_show(container, 0xFFFFFFFF)?;
0133134 let mut accumulated_utf16 = 0;
135 let mut last_node: Option<web_sys::Node> = None;
136+ let mut skip_until_exit: Option<web_sys::Element> = None;
137138 while let Some(node) = walker.next_node()? {
139+ // Check if we've exited the non-editable subtree
140+ if let Some(ref skip_elem) = skip_until_exit {
141+ if !skip_elem.contains(Some(&node)) {
142+ skip_until_exit = None;
143+ }
144+ }
145+146+ // Check if entering a non-editable element
147+ if skip_until_exit.is_none() {
148+ if let Some(element) = node.dyn_ref::<web_sys::Element>() {
149+ if element.get_attribute("contenteditable").as_deref() == Some("false") {
150+ skip_until_exit = Some(element.clone());
151+ continue;
152+ }
153+ }
154+ }
155+156+ // Skip everything inside non-editable regions
157+ if skip_until_exit.is_some() {
158+ continue;
159+ }
160+161+ // Only process text nodes
162+ if node.node_type() != web_sys::Node::TEXT_NODE {
163+ continue;
164+ }
165+166 last_node = Some(node.clone());
167168 if let Some(text) = node.text_content() {
···161 })?;
162163 // Calculate UTF-16 offset from start of container to the position
0164 let mut utf16_offset_in_container = 0;
165166- if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 4) {
167- while let Ok(Some(text_node)) = walker.next_node() {
168- if &text_node == node {
169- utf16_offset_in_container += offset_in_text_node;
170- break;
000000171 }
172173- if let Some(text) = text_node.text_content() {
174- utf16_offset_in_container += text.encode_utf16().count();
00000000000000000000000175 }
176 }
177 }
···331) -> bool {
332 false
333}
0
···161 })?;
162163 // Calculate UTF-16 offset from start of container to the position
164+ // Skip text nodes inside contenteditable="false" elements (like embeds)
165 let mut utf16_offset_in_container = 0;
166167+ // Use SHOW_ALL (0xFFFFFFFF) to see element boundaries for tracking non-editable regions
168+ if let Ok(walker) = dom_document.create_tree_walker_with_what_to_show(&container, 0xFFFFFFFF) {
169+ // Track the non-editable element we're inside (if any)
170+ let mut skip_until_exit: Option<web_sys::Element> = None;
171+172+ while let Ok(Some(dom_node)) = walker.next_node() {
173+ // Check if we've exited the non-editable subtree
174+ if let Some(ref skip_elem) = skip_until_exit {
175+ if !skip_elem.contains(Some(&dom_node)) {
176+ skip_until_exit = None;
177+ }
178 }
179180+ // Check if entering a non-editable element
181+ if skip_until_exit.is_none() {
182+ if let Some(element) = dom_node.dyn_ref::<web_sys::Element>() {
183+ if element.get_attribute("contenteditable").as_deref() == Some("false") {
184+ skip_until_exit = Some(element.clone());
185+ continue;
186+ }
187+ }
188+ }
189+190+ // Skip everything inside non-editable regions
191+ if skip_until_exit.is_some() {
192+ continue;
193+ }
194+195+ // Only process text nodes
196+ if dom_node.node_type() == web_sys::Node::TEXT_NODE {
197+ if &dom_node == node {
198+ utf16_offset_in_container += offset_in_text_node;
199+ break;
200+ }
201+202+ if let Some(text) = dom_node.text_content() {
203+ utf16_offset_in_container += text.encode_utf16().count();
204+ }
205 }
206 }
207 }
···361) -> bool {
362 false
363}
364+
···15use weaver_api::com_atproto::repo::strong_ref::StrongRef;
16use weaver_api::com_atproto::repo::{create_record::CreateRecord, put_record::PutRecord};
17use weaver_api::sh_weaver::embed::images::Images;
018use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds};
19use weaver_common::{WeaverError, WeaverExt};
20···34 let did = &caps[1];
35 let name = &caps[3];
36 format!("/image/{}/{}/{}", did, entry_rkey, name)
00000000000037 })
38 .into_owned()
39}
···82 // Resolve DID and PDS
83 let (did, pds_url) = match ident {
84 AtIdentifier::Did(d) => {
85- let pds = fetcher
86- .client
87- .pds_for_did(d)
88- .await
89- .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e)))?;
90 (d.clone(), pds)
91 }
92 AtIdentifier::Handle(h) => {
93- let (did, pds) = fetcher
94- .client
95- .pds_for_handle(h)
96- .await
97- .map_err(|e| WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e)))?;
98 (did, pds)
99 }
100 };
···124 // Build StrongRef from URI and CID
125 let entry_ref = StrongRef::new()
126 .uri(uri.clone().into_static())
127- .cid(record.cid.ok_or_else(|| {
128- WeaverError::InvalidNotebook("Entry response missing CID".into())
129- })?.into_static())
000130 .build();
131132 Ok(LoadedEntry {
···153 // Get images from the document
154 let editor_images = doc.images();
155156- // Build embeds if we have images
157- let entry_embeds = if editor_images.is_empty() {
00000000000000158 None
159 } else {
160- // Extract Image types from EditorImage wrappers
161- let images: Vec<_> = editor_images.iter().map(|ei| ei.image.clone()).collect();
000000000000162163 Some(EntryEmbeds {
164- images: Some(Images {
165- images,
166- extra_data: None,
167- }),
168 ..Default::default()
169 })
170 };
···192 let client = fetcher.get_client();
193 let result = if let Some(notebook) = notebook_title {
194 // Publish to a notebook via upsert_entry
195- // TODO: Need to handle path rewriting for notebook case
196- // For now, use content as-is (notebook entries use different path scheme anyway)
0197 let entry = Entry::new()
198- .content(doc.content())
199 .title(doc.title())
200 .path(path)
201 .created_at(Datetime::now())
···203 .maybe_embeds(entry_embeds)
204 .build();
205206- let (entry_ref, was_created) = client.upsert_entry(notebook, &doc.title(), entry).await?;
0000000000207 let uri = entry_ref.uri.clone();
208209 // Set entry_ref so subsequent publishes update this record
···368 pub document: EditorDocument,
369 /// Storage key for the draft
370 pub draft_key: String,
000371}
372373/// Publish button component with notebook selection.
···377 let auth_state = use_context::<Signal<AuthState>>();
378379 let mut show_dialog = use_signal(|| false);
380- let mut notebook_title = use_signal(|| String::from("Default"));
381- let mut use_notebook = use_signal(|| true);
00382 let mut is_publishing = use_signal(|| false);
383 let mut error_message: Signal<Option<String>> = use_signal(|| None);
384 let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None);
···420 error_message.set(None);
421422 let mut doc_snapshot = doc_snapshot;
423- match publish_entry(&fetcher, &mut doc_snapshot, notebook.as_deref(), &draft_key).await {
0424 Ok(result) => {
425 success_uri.set(Some(result.uri().clone()));
426 }
···460 h2 { "Publish Entry" }
461462 if let Some(uri) = success_uri() {
463- div { class: "publish-success",
464- p { "Entry published successfully!" }
465- a {
466- href: "{uri}",
467- target: "_blank",
468- "View entry →"
469- }
470- button {
471- class: "publish-done",
472- onclick: close_dialog,
473- "Done"
000000000000000474 }
475 }
476 } else {
···15use weaver_api::com_atproto::repo::strong_ref::StrongRef;
16use weaver_api::com_atproto::repo::{create_record::CreateRecord, put_record::PutRecord};
17use weaver_api::sh_weaver::embed::images::Images;
18+use weaver_api::sh_weaver::embed::records::{RecordEmbed, Records};
19use weaver_api::sh_weaver::notebook::entry::{Entry, EntryEmbeds};
20use weaver_common::{WeaverError, WeaverExt};
21···35 let did = &caps[1];
36 let name = &caps[3];
37 format!("/image/{}/{}/{}", did, entry_rkey, name)
38+ })
39+ .into_owned()
40+}
41+42+/// Rewrite draft paths for notebook entries.
43+///
44+/// Converts `/image/{did}/draft/{blob_rkey}/{name}` to `/image/{notebook}/{name}`
45+fn rewrite_draft_paths_for_notebook(content: &str, notebook_key: &str) -> String {
46+ DRAFT_IMAGE_PATH_REGEX
47+ .replace_all(content, |caps: ®ex_lite::Captures| {
48+ let name = &caps[3];
49+ format!("/image/{}/{}", notebook_key, name)
50 })
51 .into_owned()
52}
···95 // Resolve DID and PDS
96 let (did, pds_url) = match ident {
97 AtIdentifier::Did(d) => {
98+ let pds = fetcher.client.pds_for_did(d).await.map_err(|e| {
99+ WeaverError::InvalidNotebook(format!("Failed to resolve DID: {}", e))
100+ })?;
00101 (d.clone(), pds)
102 }
103 AtIdentifier::Handle(h) => {
104+ let (did, pds) = fetcher.client.pds_for_handle(h).await.map_err(|e| {
105+ WeaverError::InvalidNotebook(format!("Failed to resolve handle: {}", e))
106+ })?;
00107 (did, pds)
108 }
109 };
···133 // Build StrongRef from URI and CID
134 let entry_ref = StrongRef::new()
135 .uri(uri.clone().into_static())
136+ .cid(
137+ record
138+ .cid
139+ .ok_or_else(|| WeaverError::InvalidNotebook("Entry response missing CID".into()))?
140+ .into_static(),
141+ )
142 .build();
143144 Ok(LoadedEntry {
···165 // Get images from the document
166 let editor_images = doc.images();
167168+ // Resolve AT embed URIs to StrongRefs
169+ let at_embed_uris = doc.at_embed_uris();
170+ let mut record_embeds: Vec<RecordEmbed<'static>> = Vec::new();
171+ for uri in at_embed_uris {
172+ match fetcher.confirm_record_ref(&uri).await {
173+ Ok(strong_ref) => {
174+ record_embeds.push(RecordEmbed::new().record(strong_ref).build());
175+ }
176+ Err(e) => {
177+ tracing::warn!("Failed to resolve embed {}: {}", uri, e);
178+ }
179+ }
180+ }
181+182+ // Build embeds if we have images or records
183+ let entry_embeds = if editor_images.is_empty() && record_embeds.is_empty() {
184 None
185 } else {
186+ let images = if editor_images.is_empty() {
187+ None
188+ } else {
189+ Some(Images {
190+ images: editor_images.iter().map(|ei| ei.image.clone()).collect(),
191+ extra_data: None,
192+ })
193+ };
194+195+ let records = if record_embeds.is_empty() {
196+ None
197+ } else {
198+ Some(Records::new().records(record_embeds).build())
199+ };
200201 Some(EntryEmbeds {
202+ images,
203+ records,
00204 ..Default::default()
205 })
206 };
···228 let client = fetcher.get_client();
229 let result = if let Some(notebook) = notebook_title {
230 // Publish to a notebook via upsert_entry
231+ // Rewrite draft image paths to notebook paths: /image/{notebook}/{name}
232+ let content = rewrite_draft_paths_for_notebook(&doc.content(), notebook);
233+234 let entry = Entry::new()
235+ .content(content)
236 .title(doc.title())
237 .path(path)
238 .created_at(Datetime::now())
···240 .maybe_embeds(entry_embeds)
241 .build();
242243+ // Pass existing rkey if re-publishing (to allow title changes without creating new entry)
244+ let doc_entry_ref = doc.entry_ref();
245+ let existing_rkey = doc_entry_ref.as_ref().and_then(|r| r.uri.rkey());
246+ let (entry_ref, was_created) = client
247+ .upsert_entry(
248+ notebook,
249+ &doc.title(),
250+ entry,
251+ existing_rkey.map(|r| r.0.as_str()),
252+ )
253+ .await?;
254 let uri = entry_ref.uri.clone();
255256 // Set entry_ref so subsequent publishes update this record
···415 pub document: EditorDocument,
416 /// Storage key for the draft
417 pub draft_key: String,
418+ /// Pre-selected notebook (from URL param)
419+ #[props(optional)]
420+ pub target_notebook: Option<String>,
421}
422423/// Publish button component with notebook selection.
···427 let auth_state = use_context::<Signal<AuthState>>();
428429 let mut show_dialog = use_signal(|| false);
430+ let mut notebook_title = use_signal(|| {
431+ props.target_notebook.clone().unwrap_or_else(|| String::from("Default"))
432+ });
433+ let mut use_notebook = use_signal(|| props.target_notebook.is_some());
434 let mut is_publishing = use_signal(|| false);
435 let mut error_message: Signal<Option<String>> = use_signal(|| None);
436 let mut success_uri: Signal<Option<AtUri<'static>>> = use_signal(|| None);
···472 error_message.set(None);
473474 let mut doc_snapshot = doc_snapshot;
475+ match publish_entry(&fetcher, &mut doc_snapshot, notebook.as_deref(), &draft_key).await
476+ {
477 Ok(result) => {
478 success_uri.set(Some(result.uri().clone()));
479 }
···513 h2 { "Publish Entry" }
514515 if let Some(uri) = success_uri() {
516+ {
517+ // Construct web URL from AT-URI
518+ let did = uri.authority();
519+ let web_url = if use_notebook() {
520+ // Notebook entry: /{did}/{notebook}/{entry_path}
521+ format!("/{}/{}/{}", did, notebook_title(), doc.path())
522+ } else {
523+ // Standalone entry: /{did}/e/{rkey}
524+ let rkey = uri.rkey().map(|r| r.0.as_str()).unwrap_or("");
525+ format!("/{}/e/{}", did, rkey)
526+ };
527+528+ rsx! {
529+ div { class: "publish-success",
530+ p { "Entry published successfully!" }
531+ a {
532+ href: "{web_url}",
533+ target: "_blank",
534+ "View entry → "
535+ }
536+ button {
537+ class: "publish-done",
538+ onclick: close_dialog,
539+ "Done"
540+ }
541+ }
542 }
543 }
544 } else {
+65-49
crates/weaver-app/src/components/editor/render.rs
···11use loro::LoroText;
12use markdown_weaver::Parser;
13use std::ops::Range;
01415/// Cache for incremental paragraph rendering.
16/// Stores previously rendered paragraphs to avoid re-rendering unchanged content.
···104///
105/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs.
106/// For "safe" edits (no boundary changes), skips boundary rediscovery entirely.
0000000107pub fn render_paragraphs_incremental(
108 text: &LoroText,
109 cache: Option<&RenderCache>,
110 edit: Option<&EditInfo>,
111 image_resolver: Option<&EditorImageResolver>,
112-) -> (Vec<ParagraphRender>, RenderCache) {
000000113 let source = text.to_string();
114115 // Handle empty document
···139 next_syn_id: 0,
140 };
141142- return (vec![para], new_cache);
143 }
144145 // Determine if we can use fast path (skip boundary discovery)
···218 .run()
219 {
220 Ok(result) => result.paragraph_ranges,
221- Err(_) => return (Vec::new(), RenderCache::default()),
222 }
223 };
224···241 // Render paragraphs, reusing cache where possible
242 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
243 let mut new_cached = Vec::with_capacity(paragraph_ranges.len());
0244 let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0);
245 let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0);
246···286 // Use provided resolver or empty default
287 let resolver = image_resolver.cloned().unwrap_or_default();
288289- let (mut offset_map, mut syntax_spans) =
290- match EditorWriter::<_, _, ()>::new_with_offsets(
00291 ¶_source,
292 ¶_text,
293 parser,
294 &mut output,
295 node_id_offset,
296 syn_id_offset,
00297 )
298 .with_image_resolver(&resolver)
299- .run()
300- {
301- Ok(result) => {
302- // Update node ID offset
303- let max_node_id = result
304- .offset_maps
305- .iter()
306- .filter_map(|m| {
307- m.node_id
308- .strip_prefix("n")
309- .and_then(|s| s.parse::<usize>().ok())
310- })
311- .max()
312- .unwrap_or(node_id_offset);
313- node_id_offset = max_node_id + 1;
314315- // Update syn ID offset
316- let max_syn_id = result
317- .syntax_spans
318- .iter()
319- .filter_map(|s| {
320- s.syn_id
321- .strip_prefix("s")
322- .and_then(|id| id.parse::<usize>().ok())
323- })
324- .max()
325- .unwrap_or(syn_id_offset.saturating_sub(1));
326- syn_id_offset = max_syn_id + 1;
327328- (result.offset_maps, result.syntax_spans)
329- }
330- Err(_) => (Vec::new(), Vec::new()),
331- };
00000000000000000000000332333- // Adjust offsets to document coordinates
334- let para_char_start = char_range.start;
335- let para_byte_start = byte_range.start;
336- for mapping in &mut offset_map {
337- mapping.byte_range.start += para_byte_start;
338- mapping.byte_range.end += para_byte_start;
339- mapping.char_range.start += para_char_start;
340- mapping.char_range.end += para_char_start;
341- }
342- for span in &mut syntax_spans {
343- span.adjust_positions(para_char_start as isize);
344- }
34500346 (output, offset_map, syntax_spans)
347 };
348···448 next_syn_id: syn_id_offset,
449 };
450451- (paragraphs_with_gaps, new_cache)
452}
···11use loro::LoroText;
12use markdown_weaver::Parser;
13use std::ops::Range;
14+use weaver_common::{EntryIndex, ResolvedContent};
1516/// Cache for incremental paragraph rendering.
17/// Stores previously rendered paragraphs to avoid re-rendering unchanged content.
···105///
106/// Uses cached paragraph renders when possible, only re-rendering changed paragraphs.
107/// For "safe" edits (no boundary changes), skips boundary rediscovery entirely.
108+///
109+/// # Parameters
110+/// - `entry_index`: Optional index for wikilink validation (adds link-valid/link-broken classes)
111+/// - `resolved_content`: Pre-resolved embed content for sync rendering
112+///
113+/// # Returns
114+/// (paragraphs, cache, collected_refs) - collected_refs contains wikilinks and AT embeds found during render
115pub fn render_paragraphs_incremental(
116 text: &LoroText,
117 cache: Option<&RenderCache>,
118 edit: Option<&EditInfo>,
119 image_resolver: Option<&EditorImageResolver>,
120+ entry_index: Option<&EntryIndex>,
121+ resolved_content: &ResolvedContent,
122+) -> (
123+ Vec<ParagraphRender>,
124+ RenderCache,
125+ Vec<weaver_common::ExtractedRef>,
126+) {
127 let source = text.to_string();
128129 // Handle empty document
···153 next_syn_id: 0,
154 };
155156+ return (vec![para], new_cache, vec![]);
157 }
158159 // Determine if we can use fast path (skip boundary discovery)
···232 .run()
233 {
234 Ok(result) => result.paragraph_ranges,
235+ Err(_) => return (Vec::new(), RenderCache::default(), vec![]),
236 }
237 };
238···255 // Render paragraphs, reusing cache where possible
256 let mut paragraphs = Vec::with_capacity(paragraph_ranges.len());
257 let mut new_cached = Vec::with_capacity(paragraph_ranges.len());
258+ let mut all_refs: Vec<weaver_common::ExtractedRef> = Vec::new();
259 let mut node_id_offset = cache.map(|c| c.next_node_id).unwrap_or(0);
260 let mut syn_id_offset = cache.map(|c| c.next_syn_id).unwrap_or(0);
261···301 // Use provided resolver or empty default
302 let resolver = image_resolver.cloned().unwrap_or_default();
303304+ // Build writer with optional entry index for wikilink validation
305+ // Pass paragraph's document-level offsets so all embedded char/byte positions are absolute
306+ let mut writer =
307+ EditorWriter::<_, _, &ResolvedContent, &EditorImageResolver>::new_with_all_offsets(
308 ¶_source,
309 ¶_text,
310 parser,
311 &mut output,
312 node_id_offset,
313 syn_id_offset,
314+ char_range.start,
315+ byte_range.start,
316 )
317 .with_image_resolver(&resolver)
318+ .with_embed_provider(resolved_content);
00000000000000319320+ if let Some(idx) = entry_index {
321+ writer = writer.with_entry_index(idx);
322+ }
000000000323324+ let (mut offset_map, mut syntax_spans) = match writer.run() {
325+ Ok(result) => {
326+ // Update node ID offset
327+ let max_node_id = result
328+ .offset_maps
329+ .iter()
330+ .filter_map(|m| {
331+ m.node_id
332+ .strip_prefix("n")
333+ .and_then(|s| s.parse::<usize>().ok())
334+ })
335+ .max()
336+ .unwrap_or(node_id_offset);
337+ node_id_offset = max_node_id + 1;
338+339+ // Update syn ID offset
340+ let max_syn_id = result
341+ .syntax_spans
342+ .iter()
343+ .filter_map(|s| {
344+ s.syn_id
345+ .strip_prefix("s")
346+ .and_then(|id| id.parse::<usize>().ok())
347+ })
348+ .max()
349+ .unwrap_or(syn_id_offset.saturating_sub(1));
350+ syn_id_offset = max_syn_id + 1;
351352+ // Collect refs from this paragraph
353+ all_refs.extend(result.collected_refs);
354+355+ (result.offset_maps, result.syntax_spans)
356+ }
357+ Err(_) => (Vec::new(), Vec::new()),
358+ };
00000359360+ // Offsets are already document-absolute since we pass char_range.start/byte_range.start
361+ // to the writer constructor
362 (output, offset_map, syntax_spans)
363 };
364···464 next_syn_id: syn_id_offset,
465 };
466467+ (paragraphs_with_gaps, new_cache, all_refs)
468}
+27-7
crates/weaver-app/src/components/editor/tests.rs
···5use super::render::render_paragraphs_incremental;
6use loro::LoroDoc;
7use serde::Serialize;
089/// Serializable version of ParagraphRender for snapshot testing.
10#[derive(Debug, Serialize)]
···57 let doc = LoroDoc::new();
58 let text = doc.get_text("content");
59 text.insert(0, input).unwrap();
60- let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None);
061 paragraphs.iter().map(TestParagraph::from).collect()
62}
63···645646 // Initial state: "#" is a valid empty heading
647 text.insert(0, "#").unwrap();
648- let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None);
0649650 eprintln!("State 1 ('#'): {}", paras1[0].html);
651 assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
···656657 // Transition: add "t" to make "#t" - no longer a heading
658 text.insert(1, "t").unwrap();
659- let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None, None);
0000000660661 eprintln!("State 2 ('#t'): {}", paras2[0].html);
662 assert!(
···765 let doc = LoroDoc::new();
766 let text = doc.get_text("content");
767 text.insert(0, input).unwrap();
768- let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None);
0769770 // With standard \n\n break, we expect 2 paragraphs (no gap element)
771 // Paragraph ranges include some trailing whitespace from markdown parsing
···794 let doc = LoroDoc::new();
795 let text = doc.get_text("content");
796 text.insert(0, input).unwrap();
797- let (paragraphs, _cache) = render_paragraphs_incremental(&text, None, None, None);
0798799 // With extra newlines, we expect 3 elements: para, gap, para
800 assert_eq!(
···894 let text = doc.get_text("content");
895 text.insert(0, input).unwrap();
896897- let (paras1, cache1) = render_paragraphs_incremental(&text, None, None, None);
0898 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
899900 // Second render with same content should reuse cache
901- let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None, None);
0000000902903 // Should produce identical output
904 assert_eq!(paras1.len(), paras2.len());
···5use super::render::render_paragraphs_incremental;
6use loro::LoroDoc;
7use serde::Serialize;
8+use weaver_common::ResolvedContent;
910/// Serializable version of ParagraphRender for snapshot testing.
11#[derive(Debug, Serialize)]
···58 let doc = LoroDoc::new();
59 let text = doc.get_text("content");
60 text.insert(0, input).unwrap();
61+ let (paragraphs, _cache, _refs) =
62+ render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default());
63 paragraphs.iter().map(TestParagraph::from).collect()
64}
65···647648 // Initial state: "#" is a valid empty heading
649 text.insert(0, "#").unwrap();
650+ let (paras1, cache1, _refs1) =
651+ render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default());
652653 eprintln!("State 1 ('#'): {}", paras1[0].html);
654 assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
···659660 // Transition: add "t" to make "#t" - no longer a heading
661 text.insert(1, "t").unwrap();
662+ let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
663+ &text,
664+ Some(&cache1),
665+ None,
666+ None,
667+ None,
668+ &ResolvedContent::default(),
669+ );
670671 eprintln!("State 2 ('#t'): {}", paras2[0].html);
672 assert!(
···775 let doc = LoroDoc::new();
776 let text = doc.get_text("content");
777 text.insert(0, input).unwrap();
778+ let (paragraphs, _cache, _refs) =
779+ render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default());
780781 // With standard \n\n break, we expect 2 paragraphs (no gap element)
782 // Paragraph ranges include some trailing whitespace from markdown parsing
···805 let doc = LoroDoc::new();
806 let text = doc.get_text("content");
807 text.insert(0, input).unwrap();
808+ let (paragraphs, _cache, _refs) =
809+ render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default());
810811 // With extra newlines, we expect 3 elements: para, gap, para
812 assert_eq!(
···906 let text = doc.get_text("content");
907 text.insert(0, input).unwrap();
908909+ let (paras1, cache1, _refs1) =
910+ render_paragraphs_incremental(&text, None, None, None, None, &ResolvedContent::default());
911 assert!(!cache1.paragraphs.is_empty(), "Cache should be populated");
912913 // Second render with same content should reuse cache
914+ let (paras2, _cache2, _refs2) = render_paragraphs_incremental(
915+ &text,
916+ Some(&cache1),
917+ None,
918+ None,
919+ None,
920+ &ResolvedContent::default(),
921+ );
922923 // Should produce identical output
924 assert_eq!(paras1.len(), paras2.len());
···348 }
349}
3500351#[derive(Clone)]
352pub struct Fetcher {
353 pub client: Arc<Client>,
···356 (AtIdentifier<'static>, SmolStr),
357 Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>,
358 >,
359+ /// Maps notebook title OR path to ident (book_cache accepts either as key)
360+ #[cfg(feature = "server")]
361+ notebook_key_cache: cache_impl::Cache<SmolStr, AtIdentifier<'static>>,
362 #[cfg(feature = "server")]
363 entry_cache: cache_impl::Cache<
364 (AtIdentifier<'static>, SmolStr),
···371 cache_impl::Cache<(AtIdentifier<'static>, SmolStr), Arc<StandaloneEntryData>>,
372}
3730374impl Fetcher {
375 pub fn new(client: OAuthClient<JacquardResolver, AuthStore>) -> Self {
376 Self {
377 client: Arc::new(Client::new(client)),
378 #[cfg(feature = "server")]
379 book_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)),
380+ #[cfg(feature = "server")]
381+ notebook_key_cache: cache_impl::new_cache(500, std::time::Duration::from_secs(30)),
382 #[cfg(feature = "server")]
383 entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)),
384 #[cfg(feature = "server")]
···435 {
436 let stored = Arc::new((notebook, entries));
437 #[cfg(feature = "server")]
438+ {
439+ // Cache by title
440+ cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
441+ cache_impl::insert(&self.book_cache, (ident.clone(), title), stored.clone());
442+ // Also cache by path if available
443+ if let Some(path) = stored.0.path.as_ref() {
444+ let path: SmolStr = path.as_ref().into();
445+ cache_impl::insert(&self.notebook_key_cache, path.clone(), ident.clone());
446+ cache_impl::insert(&self.book_cache, (ident, path), stored.clone());
447+ }
448+ }
449 Ok(Some(stored))
450 } else {
451 Err(dioxus::CapturedError::from_display("Notebook not found"))
452 }
453 }
454455+ /// Get notebook by title or path (for image resolution without knowing owner).
456+ /// Checks notebook_key_cache first, falls back to UFOS discovery.
457+ #[cfg(feature = "server")]
458+ pub async fn get_notebook_by_key(
459+ &self,
460+ key: &str,
461+ ) -> Result<Option<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> {
462+ let key: SmolStr = key.into();
463+464+ // Check cache first (key could be title or path)
465+ if let Some(ident) = cache_impl::get(&self.notebook_key_cache, &key) {
466+ return self.get_notebook(ident, key).await;
467+ }
468+469+ // Fallback: query UFOS and populate caches
470+ let notebooks = self.fetch_notebooks_from_ufos().await?;
471+ Ok(notebooks.into_iter().find(|arc| {
472+ let (view, _) = arc.as_ref();
473+ view.title.as_deref() == Some(key.as_str())
474+ || view.path.as_deref() == Some(key.as_str())
475+ }))
476+ }
477+478 pub async fn get_entry(
479 &self,
480 ident: AtIdentifier<'static>,
···545546 let result = Arc::new((notebook, entries));
547 #[cfg(feature = "server")]
548+ {
549+ // Cache by title
550+ cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
551+ cache_impl::insert(
552+ &self.book_cache,
553+ (ident.clone(), title),
554+ result.clone(),
555+ );
556+ // Also cache by path if available
557+ if let Some(path) = result.0.path.as_ref() {
558+ let path: SmolStr = path.as_ref().into();
559+ cache_impl::insert(
560+ &self.notebook_key_cache,
561+ path.clone(),
562+ ident.clone(),
563+ );
564+ cache_impl::insert(&self.book_cache, (ident, path), result.clone());
565+ }
566+ }
567 notebooks.push(result);
568 }
569 Err(_) => continue, // Skip notebooks that fail to load
···689690 let result = Arc::new((notebook, entries));
691 #[cfg(feature = "server")]
692+ {
693+ // Cache by title
694+ cache_impl::insert(
695+ &self.notebook_key_cache,
696+ title.clone(),
697+ ident.clone(),
698+ );
699+ cache_impl::insert(
700+ &self.book_cache,
701+ (ident.clone(), title),
702+ result.clone(),
703+ );
704+ // Also cache by path if available
705+ if let Some(path) = result.0.path.as_ref() {
706+ let path: SmolStr = path.as_ref().into();
707+ cache_impl::insert(
708+ &self.notebook_key_cache,
709+ path.clone(),
710+ ident.clone(),
711+ );
712+ cache_impl::insert(&self.book_cache, (ident, path), result.clone());
713+ }
714+ }
715 notebooks.push(result);
716 }
717 Err(_) => continue, // Skip notebooks that fail to load
+10-9
crates/weaver-app/src/main.rs
···173174 #[cfg(feature = "fullstack-server")]
175 let router = {
176- use jacquard::client::UnauthenticatedSession;
177 let fetcher = Arc::new(fetch::Fetcher::new(OAuthClient::new(
178 AuthStore::new(),
179 ClientData::new_public(CONFIG.oauth.clone()),
180 )));
181- let blob_cache = Arc::new(BlobCache::new(Arc::new(
182- UnauthenticatedSession::new_public(),
183- )));
184 axum::Router::new()
185 .route("/favicon.ico", get(favicon))
186 // Server side render the application, serve static assets, and register server functions
···353}
354355#[cfg(all(feature = "fullstack-server", feature = "server"))]
356-#[get("/{_notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)]
357-pub async fn image_named(_notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> {
358 if let Some(bytes) = blob_cache.get_named(&name) {
359- Ok(build_image_response(bytes))
360- } else {
361- Ok(image_not_found())
0000362 }
363}
364
···173174 #[cfg(feature = "fullstack-server")]
175 let router = {
0176 let fetcher = Arc::new(fetch::Fetcher::new(OAuthClient::new(
177 AuthStore::new(),
178 ClientData::new_public(CONFIG.oauth.clone()),
179 )));
180+ let blob_cache = Arc::new(BlobCache::new(fetcher.clone()));
00181 axum::Router::new()
182 .route("/favicon.ico", get(favicon))
183 // Server side render the application, serve static assets, and register server functions
···350}
351352#[cfg(all(feature = "fullstack-server", feature = "server"))]
353+#[get("/{notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)]
354+pub async fn image_named(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> {
355 if let Some(bytes) = blob_cache.get_named(&name) {
356+ return Ok(build_image_response(bytes));
357+ }
358+359+ // Try to resolve from notebook
360+ match blob_cache.resolve_from_notebook(¬ebook, &name).await {
361+ Ok(bytes) => Ok(build_image_response(bytes)),
362+ Err(_) => Ok(image_not_found()),
363 }
364}
365
+30-1
crates/weaver-app/src/views/drafts.rs
···208 rkey: ReadSignal<SmolStr>,
209) -> Element {
210 use crate::components::editor::MarkdownEditor;
0211 use crate::views::editor::EditorCss;
0212213 // Construct AT-URI for the entry
214 let entry_uri =
215 use_memo(move || format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey()));
21600000000000000000000000217 rsx! {
218 EditorCss {}
219 div { class: "editor-page",
220- MarkdownEditor { entry_uri: Some(entry_uri()), target_notebook: Some(book_title()) }
0000221 }
222 }
223}
···208 rkey: ReadSignal<SmolStr>,
209) -> Element {
210 use crate::components::editor::MarkdownEditor;
211+ use crate::data::use_notebook_entries;
212 use crate::views::editor::EditorCss;
213+ use weaver_common::EntryIndex;
214215 // Construct AT-URI for the entry
216 let entry_uri =
217 use_memo(move || format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey()));
218219+ // Fetch notebook entries for wikilink validation
220+ let (_entries_resource, entries_memo) = use_notebook_entries(ident, book_title);
221+222+ // Build entry index from notebook entries
223+ let entry_index = use_memo(move || {
224+ entries_memo().map(|entries| {
225+ let mut index = EntryIndex::new();
226+ let ident_str = ident().to_string();
227+ let book = book_title();
228+ for book_entry in &entries {
229+ // EntryView has optional title/path
230+ let title = book_entry.entry.title.as_ref().map(|t| t.as_str()).unwrap_or("");
231+ let path = book_entry.entry.path.as_ref().map(|p| p.as_str()).unwrap_or("");
232+ if !title.is_empty() || !path.is_empty() {
233+ // Build canonical URL: /{ident}/{book}/{path}
234+ let canonical_url = format!("/{}/{}/{}", ident_str, book, path);
235+ index.add_entry(title, path, canonical_url);
236+ }
237+ }
238+ index
239+ })
240+ });
241+242 rsx! {
243 EditorCss {}
244 div { class: "editor-page",
245+ MarkdownEditor {
246+ entry_uri: Some(entry_uri()),
247+ target_notebook: Some(book_title()),
248+ entry_index: entry_index(),
249+ }
250 }
251 }
252}
+1-1
crates/weaver-cli/src/main.rs
···389 use jacquard::http_client::HttpClient;
390 use weaver_common::WeaverExt;
391 let (entry_ref, was_created) = agent
392- .upsert_entry(&title, entry_title.as_ref(), entry)
393 .await?;
394395 if was_created {
···389 use jacquard::http_client::HttpClient;
390 use weaver_common::WeaverExt;
391 let (entry_ref, was_created) = agent
392+ .upsert_entry(&title, entry_title.as_ref(), entry, None)
393 .await?;
394395 if was_created {
+55-2
crates/weaver-common/src/agent.rs
···250 }
251 }
252253- /// Find or create an entry within a notebook by title
254 ///
255 /// Multi-step workflow:
256 /// 1. Find the notebook by title
257- /// 2. Check notebook's entry_list for entry with matching title
258 /// 3. If found: update the entry with new content
259 /// 4. If not found: create new entry and append to notebook's entry_list
260 ///
000261 /// Returns (entry_ref, was_created)
262 fn upsert_entry(
263 &self,
264 notebook_title: &str,
265 entry_title: &str,
266 entry: entry::Entry<'_>,
0267 ) -> impl Future<Output = Result<(StrongRef<'static>, bool), WeaverError>>
268 where
269 Self: Sized,
···276277 // Find or create notebook
278 let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?;
0000000000000000000000000000000000000000000000000279280 // Fast path: if notebook is empty, skip search and create directly
281 if entry_refs.is_empty() {
···250 }
251 }
252253+ /// Find or create an entry within a notebook
254 ///
255 /// Multi-step workflow:
256 /// 1. Find the notebook by title
257+ /// 2. If existing_rkey is provided, match by rkey; otherwise match by title
258 /// 3. If found: update the entry with new content
259 /// 4. If not found: create new entry and append to notebook's entry_list
260 ///
261+ /// The `existing_rkey` parameter allows updating an entry even if its title changed,
262+ /// and enables pre-generating rkeys for path rewriting before publish.
263+ ///
264 /// Returns (entry_ref, was_created)
265 fn upsert_entry(
266 &self,
267 notebook_title: &str,
268 entry_title: &str,
269 entry: entry::Entry<'_>,
270+ existing_rkey: Option<&str>,
271 ) -> impl Future<Output = Result<(StrongRef<'static>, bool), WeaverError>>
272 where
273 Self: Sized,
···280281 // Find or create notebook
282 let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?;
283+284+ // If we have an existing rkey, try to find and update that specific entry
285+ if let Some(rkey) = existing_rkey {
286+ // Check if this entry exists in the notebook by comparing rkeys
287+ for entry_ref in &entry_refs {
288+ let ref_rkey = entry_ref.uri.rkey().map(|r| r.0.as_str());
289+ if ref_rkey == Some(rkey) {
290+ // Found it - update
291+ let output = self
292+ .update_record::<entry::Entry>(&entry_ref.uri, |e| {
293+ e.content = entry.content.clone();
294+ e.title = entry.title.clone();
295+ e.path = entry.path.clone();
296+ e.embeds = entry.embeds.clone();
297+ e.tags = entry.tags.clone();
298+ })
299+ .await?;
300+ let updated_ref = StrongRef::new()
301+ .uri(output.uri.into_static())
302+ .cid(output.cid.into_static())
303+ .build();
304+ return Ok((updated_ref, false));
305+ }
306+ }
307+308+ // Entry with this rkey not in notebook - create with specific rkey
309+ let response = self
310+ .create_record(entry, Some(RecordKey::any(rkey)?))
311+ .await?;
312+ let new_ref = StrongRef::new()
313+ .uri(response.uri.clone().into_static())
314+ .cid(response.cid.clone().into_static())
315+ .build();
316+317+ use weaver_api::sh_weaver::notebook::book::Book;
318+ let notebook_entry_ref = StrongRef::new()
319+ .uri(response.uri.into_static())
320+ .cid(response.cid.into_static())
321+ .build();
322+323+ self.update_record::<Book>(¬ebook_uri, |book| {
324+ book.entry_list.push(notebook_entry_ref);
325+ })
326+ .await?;
327+328+ return Ok((new_ref, true));
329+ }
330+331+ // No existing rkey - use title-based matching (original behavior)
332333 // Fast path: if notebook is empty, skip search and create directly
334 if entry_refs.is_empty() {
+4
crates/weaver-common/src/lib.rs
···3pub mod agent;
4pub mod constellation;
5pub mod error;
06pub mod worker_rt;
78// Re-export jacquard for convenience
9pub use agent::WeaverExt;
10pub use error::WeaverError;
00011pub use jacquard;
12use jacquard::CowStr;
13use jacquard::client::{Agent, AgentSession};
···3pub mod agent;
4pub mod constellation;
5pub mod error;
6+pub mod resolve;
7pub mod worker_rt;
89// Re-export jacquard for convenience
10pub use agent::WeaverExt;
11pub use error::WeaverError;
12+pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry};
13+#[cfg(any(test, feature = "standalone-collection"))]
14+pub use resolve::collect_refs_from_markdown;
15pub use jacquard;
16use jacquard::CowStr;
17use jacquard::client::{Agent, AgentSession};
···1+//! Wikilink and embed resolution types for rendering without network calls
2+//!
3+//! This module provides pre-resolution infrastructure so that markdown rendering
4+//! can happen synchronously without network calls in the hot path.
5+6+use std::collections::HashMap;
7+8+use jacquard::CowStr;
9+use jacquard::smol_str::SmolStr;
10+use jacquard::types::string::AtUri;
11+use weaver_api::com_atproto::repo::strong_ref::StrongRef;
12+13+/// Pre-resolved data for rendering without network calls.
14+///
15+/// Populated during an async collection phase, then passed to the sync render phase.
16+#[derive(Debug, Clone, Default)]
17+pub struct ResolvedContent {
18+ /// Wikilink target (lowercase) → resolved entry info
19+ pub entry_links: HashMap<SmolStr, ResolvedEntry>,
20+ /// AT URI → rendered HTML content
21+ pub embed_content: HashMap<AtUri<'static>, CowStr<'static>>,
22+ /// AT URI → StrongRef for populating records array
23+ pub embed_refs: Vec<StrongRef<'static>>,
24+}
25+26+/// A resolved entry reference from a wikilink
27+#[derive(Debug, Clone)]
28+pub struct ResolvedEntry {
29+ /// The canonical URL path (e.g., "/handle/notebook/entry_path")
30+ pub canonical_path: CowStr<'static>,
31+ /// The original entry title for display
32+ pub display_title: CowStr<'static>,
33+}
34+35+impl ResolvedContent {
36+ pub fn new() -> Self {
37+ Self::default()
38+ }
39+40+ /// Look up a wikilink target, returns the resolved entry if found
41+ pub fn resolve_wikilink(&self, target: &str) -> Option<&ResolvedEntry> {
42+ // Strip fragment if present
43+ let (target, _fragment) = target.split_once('#').unwrap_or((target, ""));
44+ let key = SmolStr::new(target.to_lowercase());
45+ self.entry_links.get(&key)
46+ }
47+48+ /// Get pre-rendered embed content for an AT URI
49+ pub fn get_embed_content(&self, uri: &AtUri<'_>) -> Option<&str> {
50+ // Need to look up by equivalent URI, not exact reference
51+ self.embed_content
52+ .iter()
53+ .find(|(k, _)| k.as_str() == uri.as_str())
54+ .map(|(_, v)| v.as_ref())
55+ }
56+57+ /// Add a resolved entry link
58+ pub fn add_entry(
59+ &mut self,
60+ target: &str,
61+ canonical_path: impl Into<CowStr<'static>>,
62+ display_title: impl Into<CowStr<'static>>,
63+ ) {
64+ self.entry_links.insert(
65+ SmolStr::new(target.to_lowercase()),
66+ ResolvedEntry {
67+ canonical_path: canonical_path.into(),
68+ display_title: display_title.into(),
69+ },
70+ );
71+ }
72+73+ /// Add resolved embed content
74+ pub fn add_embed(
75+ &mut self,
76+ uri: AtUri<'static>,
77+ html: impl Into<CowStr<'static>>,
78+ strong_ref: Option<StrongRef<'static>>,
79+ ) {
80+ self.embed_content.insert(uri, html.into());
81+ if let Some(sr) = strong_ref {
82+ self.embed_refs.push(sr);
83+ }
84+ }
85+}
86+87+/// Index of entries within a notebook for wikilink resolution.
88+///
89+/// Supports case-insensitive matching against entry title OR path slug.
90+#[derive(Debug, Clone, Default, PartialEq)]
91+pub struct EntryIndex {
92+ /// lowercase title → (canonical_path, original_title)
93+ by_title: HashMap<SmolStr, (CowStr<'static>, CowStr<'static>)>,
94+ /// lowercase path slug → (canonical_path, original_title)
95+ by_path: HashMap<SmolStr, (CowStr<'static>, CowStr<'static>)>,
96+}
97+98+impl EntryIndex {
99+ pub fn new() -> Self {
100+ Self::default()
101+ }
102+103+ /// Add an entry to the index
104+ pub fn add_entry(
105+ &mut self,
106+ title: &str,
107+ path: &str,
108+ canonical_url: impl Into<CowStr<'static>>,
109+ ) {
110+ let canonical: CowStr<'static> = canonical_url.into();
111+ let title_cow: CowStr<'static> = CowStr::from(title.to_string());
112+113+ self.by_title.insert(
114+ SmolStr::new(title.to_lowercase()),
115+ (canonical.clone(), title_cow.clone()),
116+ );
117+ self.by_path
118+ .insert(SmolStr::new(path.to_lowercase()), (canonical, title_cow));
119+ }
120+121+ /// Resolve a wikilink target to (canonical_path, display_title, fragment)
122+ ///
123+ /// Matches case-insensitively against title first, then path slug.
124+ /// Fragment (if present) is returned with the input's lifetime.
125+ pub fn resolve<'a, 'b>(
126+ &'a self,
127+ wikilink: &'b str,
128+ ) -> Option<(&'a str, &'a str, Option<&'b str>)> {
129+ let (target, fragment) = match wikilink.split_once('#') {
130+ Some((t, f)) => (t, Some(f)),
131+ None => (wikilink, None),
132+ };
133+ let key = SmolStr::new(target.to_lowercase());
134+135+ // Try title match first
136+ if let Some((path, title)) = self.by_title.get(&key) {
137+ return Some((path.as_ref(), title.as_ref(), fragment));
138+ }
139+140+ // Try path match
141+ if let Some((path, title)) = self.by_path.get(&key) {
142+ return Some((path.as_ref(), title.as_ref(), fragment));
143+ }
144+145+ None
146+ }
147+148+ /// Parse a wikilink into (target, fragment)
149+ pub fn parse_wikilink(wikilink: &str) -> (&str, Option<&str>) {
150+ match wikilink.split_once('#') {
151+ Some((t, f)) => (t, Some(f)),
152+ None => (wikilink, None),
153+ }
154+ }
155+156+ /// Check if the index contains any entries
157+ pub fn is_empty(&self) -> bool {
158+ self.by_title.is_empty()
159+ }
160+161+ /// Get the number of entries
162+ pub fn len(&self) -> usize {
163+ self.by_title.len()
164+ }
165+}
166+167+/// Reference extracted from markdown that needs resolution
168+#[derive(Debug, Clone, PartialEq)]
169+pub enum ExtractedRef {
170+ /// Wikilink like [[Entry Name]] or [[Entry Name#header]]
171+ Wikilink {
172+ target: String,
173+ fragment: Option<String>,
174+ display_text: Option<String>,
175+ },
176+ /// AT Protocol embed like ![[at://did/collection/rkey]] or 
177+ AtEmbed {
178+ uri: String,
179+ alt_text: Option<String>,
180+ },
181+ /// AT Protocol link like [text](at://...)
182+ AtLink { uri: String },
183+}
184+185+/// Collector for refs encountered during rendering.
186+///
187+/// Pass this to renderers to collect refs as a side effect of the render pass.
188+/// This avoids a separate parsing pass just for collection.
189+#[derive(Debug, Clone, Default)]
190+pub struct RefCollector {
191+ pub refs: Vec<ExtractedRef>,
192+}
193+194+impl RefCollector {
195+ pub fn new() -> Self {
196+ Self::default()
197+ }
198+199+ /// Record a wikilink reference
200+ pub fn add_wikilink(
201+ &mut self,
202+ target: &str,
203+ fragment: Option<&str>,
204+ display_text: Option<&str>,
205+ ) {
206+ self.refs.push(ExtractedRef::Wikilink {
207+ target: target.to_string(),
208+ fragment: fragment.map(|s| s.to_string()),
209+ display_text: display_text.map(|s| s.to_string()),
210+ });
211+ }
212+213+ /// Record an AT Protocol embed reference
214+ pub fn add_at_embed(&mut self, uri: &str, alt_text: Option<&str>) {
215+ self.refs.push(ExtractedRef::AtEmbed {
216+ uri: uri.to_string(),
217+ alt_text: alt_text.map(|s| s.to_string()),
218+ });
219+ }
220+221+ /// Record an AT Protocol link reference
222+ pub fn add_at_link(&mut self, uri: &str) {
223+ self.refs.push(ExtractedRef::AtLink {
224+ uri: uri.to_string(),
225+ });
226+ }
227+228+ /// Get wikilinks that need resolution
229+ pub fn wikilinks(&self) -> impl Iterator<Item = &str> {
230+ self.refs.iter().filter_map(|r| match r {
231+ ExtractedRef::Wikilink { target, .. } => Some(target.as_str()),
232+ _ => None,
233+ })
234+ }
235+236+ /// Get AT URIs that need fetching
237+ pub fn at_uris(&self) -> impl Iterator<Item = &str> {
238+ self.refs.iter().filter_map(|r| match r {
239+ ExtractedRef::AtEmbed { uri, .. } | ExtractedRef::AtLink { uri } => Some(uri.as_str()),
240+ _ => None,
241+ })
242+ }
243+244+ /// Take ownership of collected refs
245+ pub fn take(self) -> Vec<ExtractedRef> {
246+ self.refs
247+ }
248+}
249+250+/// Extract all references from markdown that need resolution.
251+///
252+/// **Note:** This does a separate parsing pass. For production use, prefer
253+/// passing a `RefCollector` to the renderer to collect during the render pass.
254+/// This function is primarily useful for testing or quick analysis.
255+#[cfg(any(test, feature = "standalone-collection"))]
256+pub fn collect_refs_from_markdown(markdown: &str) -> Vec<ExtractedRef> {
257+ use markdown_weaver::{Event, LinkType, Options, Parser, Tag};
258+259+ let mut collector = RefCollector::new();
260+ let options = Options::all();
261+ let parser = Parser::new_ext(markdown, options);
262+263+ for event in parser {
264+ match event {
265+ Event::Start(Tag::Link {
266+ link_type,
267+ dest_url,
268+ ..
269+ }) => {
270+ let url = dest_url.as_ref();
271+272+ if matches!(link_type, LinkType::WikiLink { .. }) {
273+ let (target, fragment) = match url.split_once('#') {
274+ Some((t, f)) => (t, Some(f)),
275+ None => (url, None),
276+ };
277+ collector.add_wikilink(target, fragment, None);
278+ } else if url.starts_with("at://") {
279+ collector.add_at_link(url);
280+ }
281+ }
282+ Event::Start(Tag::Embed {
283+ dest_url, title, ..
284+ }) => {
285+ let url = dest_url.as_ref();
286+287+ if url.starts_with("at://") || url.starts_with("did:") {
288+ let alt = if title.is_empty() {
289+ None
290+ } else {
291+ Some(title.as_ref())
292+ };
293+ collector.add_at_embed(url, alt);
294+ } else if !url.starts_with("http://") && !url.starts_with("https://") {
295+ let (target, fragment) = match url.split_once('#') {
296+ Some((t, f)) => (t, Some(f)),
297+ None => (url, None),
298+ };
299+ collector.add_wikilink(target, fragment, None);
300+ }
301+ }
302+ Event::Start(Tag::Image {
303+ dest_url, title, ..
304+ }) => {
305+ let url = dest_url.as_ref();
306+307+ if url.starts_with("at://") {
308+ let alt = if title.is_empty() {
309+ None
310+ } else {
311+ Some(title.as_ref())
312+ };
313+ collector.add_at_embed(url, alt);
314+ }
315+ }
316+ _ => {}
317+ }
318+ }
319+320+ collector.take()
321+}
322+323+#[cfg(test)]
324+mod tests {
325+ use super::*;
326+ use jacquard::IntoStatic;
327+328+ #[test]
329+ fn test_entry_index_resolve_by_title() {
330+ let mut index = EntryIndex::new();
331+ index.add_entry(
332+ "My First Note",
333+ "my_first_note",
334+ "/alice/notebook/my_first_note",
335+ );
336+337+ let result = index.resolve("My First Note");
338+ assert!(result.is_some());
339+ let (path, title, fragment) = result.unwrap();
340+ assert_eq!(path, "/alice/notebook/my_first_note");
341+ assert_eq!(title, "My First Note");
342+ assert_eq!(fragment, None);
343+ }
344+345+ #[test]
346+ fn test_entry_index_resolve_case_insensitive() {
347+ let mut index = EntryIndex::new();
348+ index.add_entry(
349+ "My First Note",
350+ "my_first_note",
351+ "/alice/notebook/my_first_note",
352+ );
353+354+ let result = index.resolve("my first note");
355+ assert!(result.is_some());
356+ }
357+358+ #[test]
359+ fn test_entry_index_resolve_by_path() {
360+ let mut index = EntryIndex::new();
361+ index.add_entry(
362+ "My First Note",
363+ "my_first_note",
364+ "/alice/notebook/my_first_note",
365+ );
366+367+ let result = index.resolve("my_first_note");
368+ assert!(result.is_some());
369+ }
370+371+ #[test]
372+ fn test_entry_index_resolve_with_fragment() {
373+ let mut index = EntryIndex::new();
374+ index.add_entry("My Note", "my_note", "/alice/notebook/my_note");
375+376+ let result = index.resolve("My Note#section");
377+ assert!(result.is_some());
378+ let (path, title, fragment) = result.unwrap();
379+ assert_eq!(path, "/alice/notebook/my_note");
380+ assert_eq!(title, "My Note");
381+ assert_eq!(fragment, Some("section"));
382+ }
383+384+ #[test]
385+ fn test_collect_refs_wikilink() {
386+ let markdown = "Check out [[My Note]] for more info.";
387+ let refs = collect_refs_from_markdown(markdown);
388+389+ assert_eq!(refs.len(), 1);
390+ assert!(matches!(
391+ &refs[0],
392+ ExtractedRef::Wikilink { target, .. } if target == "My Note"
393+ ));
394+ }
395+396+ #[test]
397+ fn test_collect_refs_at_link() {
398+ let markdown = "See [this post](at://did:plc:xyz/app.bsky.feed.post/abc)";
399+ let refs = collect_refs_from_markdown(markdown);
400+401+ assert_eq!(refs.len(), 1);
402+ assert!(matches!(
403+ &refs[0],
404+ ExtractedRef::AtLink { uri } if uri == "at://did:plc:xyz/app.bsky.feed.post/abc"
405+ ));
406+ }
407+408+ #[test]
409+ fn test_collect_refs_at_embed() {
410+ let markdown = "![[at://did:plc:xyz/app.bsky.feed.post/abc]]";
411+ let refs = collect_refs_from_markdown(markdown);
412+413+ assert_eq!(refs.len(), 1);
414+ assert!(matches!(
415+ &refs[0],
416+ ExtractedRef::AtEmbed { uri, .. } if uri == "at://did:plc:xyz/app.bsky.feed.post/abc"
417+ ));
418+ }
419+420+ #[test]
421+ fn test_resolved_content_wikilink_lookup() {
422+ let mut content = ResolvedContent::new();
423+ content.add_entry("My Note", "/alice/notebook/my_note", "My Note");
424+425+ let result = content.resolve_wikilink("my note");
426+ assert!(result.is_some());
427+ assert_eq!(
428+ result.unwrap().canonical_path.as_ref(),
429+ "/alice/notebook/my_note"
430+ );
431+ }
432+433+ #[test]
434+ fn test_resolved_content_embed_lookup() {
435+ let mut content = ResolvedContent::new();
436+ let uri = AtUri::new("at://did:plc:xyz/app.bsky.feed.post/abc").unwrap();
437+ content.add_embed(uri.into_static(), "<div>post content</div>", None);
438+439+ let lookup_uri = AtUri::new("at://did:plc:xyz/app.bsky.feed.post/abc").unwrap();
440+ let result = content.get_embed_content(&lookup_uri);
441+ assert!(result.is_some());
442+ assert_eq!(result.unwrap(), "<div>post content</div>");
443+ }
444+}
+1
crates/weaver-renderer/Cargo.toml
···25pin-utils = "0.1.0"
26pin-project = "1.1.10"
27smol_str = { version = "0.3", features = ["serde"] }
028mime-sniffer = "0.1.3"
29reqwest = { version = "0.12.7", default-features = false, features = [
30 "json",
···25pin-utils = "0.1.0"
26pin-project = "1.1.10"
27smol_str = { version = "0.3", features = ["serde"] }
28+pulldown-latex = "0.6"
29mime-sniffer = "0.1.3"
30reqwest = { version = "0.12.7", default-features = false, features = [
31 "json",
+1-1
crates/weaver-renderer/src/atproto.rs
···1415pub use client::{ClientContext, DefaultEmbedResolver, EmbedResolver};
16pub use embed_renderer::{
17- fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile,
18};
19pub use error::{AtProtoPreprocessError, ClientRenderError};
20pub use markdown_writer::MarkdownWriter;
···1415pub use client::{ClientContext, DefaultEmbedResolver, EmbedResolver};
16pub use embed_renderer::{
17+ fetch_and_render, fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile,
18};
19pub use error::{AtProtoPreprocessError, ClientRenderError};
20pub use markdown_writer::MarkdownWriter;
+196-100
crates/weaver-renderer/src/atproto/client.rs
···5 prelude::IdentityResolver,
6 types::string::{AtUri, Cid, Did},
7};
8-use markdown_weaver::{CowStr as MdCowStr, Tag, WeaverAttributes};
9use std::collections::HashMap;
10use std::sync::Arc;
11use weaver_api::sh_weaver::notebook::entry::Entry;
01213/// Trait for resolving embed content on the client side
14///
···52impl<A: AgentSession + IdentityResolver> EmbedResolver for DefaultEmbedResolver<A> {
53 async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> {
54 use crate::atproto::fetch_and_render_profile;
55- use jacquard::types::ident::AtIdentifier;
56-57- // Extract DID from authority
58- let did = match uri.authority() {
59- AtIdentifier::Did(did) => did,
60- AtIdentifier::Handle(_) => {
61- return Err(ClientRenderError::EntryFetch {
62- uri: uri.as_ref().to_string(),
63- source: "Profile URI should use DID not handle".into(),
64- });
65- }
66- };
67-68- fetch_and_render_profile(&did, &*self.agent)
69 .await
70 .map_err(|e| ClientRenderError::EntryFetch {
71 uri: uri.as_ref().to_string(),
···144 embed_resolver: Option<Arc<R>>,
145 embed_depth: usize,
1460000147 // Shared state
148 frontmatter: Frontmatter,
149 title: MdCowStr<'a>,
···160 blob_map,
161 embed_resolver: None,
162 embed_depth: 0,
00163 frontmatter: Frontmatter::default(),
164 title,
165 }
···173 blob_map: self.blob_map,
174 embed_resolver: Some(resolver),
175 embed_depth: self.embed_depth,
00176 frontmatter: self.frontmatter,
177 title: self.title,
178 }
179 }
000000000000180}
181182impl<'a, R: EmbedResolver> ClientContext<'a, R> {
···191 blob_map: self.blob_map.clone(),
192 embed_resolver: self.embed_resolver.clone(),
193 embed_depth: depth,
00194 frontmatter: self.frontmatter.clone(),
195 title: self.title.clone(),
196 }
197 }
1980000000000000000000000000000000000000000000199 fn build_blob_map<'b>(entry: &Entry<'b>) -> HashMap<BlobName<'static>, Cid<'static>> {
200 use jacquard::IntoStatic;
201···295 title,
296 id,
297 } => {
0000000000000000000000000000298 let url = dest_url.as_ref();
299300 // Try to parse as AT URI
···324 }
325326 async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> {
327- match &embed {
328- Tag::Embed {
329- embed_type,
330- dest_url,
331- title,
332- id,
333- attrs,
334- } => {
335- // If content already in attrs (from preprocessor), pass through
336- if let Some(attrs) = attrs {
337- if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") {
338- return embed;
339- }
340- }
341-342- // Check if we have a resolver
343- let Some(resolver) = &self.embed_resolver else {
344- return embed;
345- };
346-347- // Check recursion depth
348- if self.embed_depth >= MAX_EMBED_DEPTH {
349- return embed;
350- }
351-352- // Try to fetch content based on URL type
353- let content_result = if dest_url.starts_with("at://") {
354- // AT Protocol embed
355- if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) {
356- if at_uri.collection().is_none() && at_uri.rkey().is_none() {
357- // Profile embed
358- resolver.resolve_profile(&at_uri).await
359- } else {
360- // Post/record embed
361- resolver.resolve_post(&at_uri).await
362- }
363- } else {
364- return embed;
365- }
366- } else if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
367- // Markdown embed (could be other types, but assume markdown for now)
368- resolver
369- .resolve_markdown(dest_url.as_ref(), self.embed_depth + 1)
370- .await
371- } else {
372- // Local path or other - skip for now
373- return embed;
374- };
375376- // If we got content, attach it to attrs
377- if let Ok(content) = content_result {
378- let mut new_attrs = attrs.clone().unwrap_or_else(|| WeaverAttributes {
379- classes: vec![],
380- attrs: vec![],
381- });
382383- new_attrs.attrs.push(("content".into(), content.into()));
0384385- // Add metadata for client-side enhancement
386- if dest_url.starts_with("at://") {
387- new_attrs
388- .attrs
389- .push(("data-embed-uri".into(), dest_url.clone()));
390391- if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) {
392- if at_uri.collection().is_none() {
393- new_attrs
394- .attrs
395- .push(("data-embed-type".into(), "profile".into()));
396- } else {
397- new_attrs
398- .attrs
399- .push(("data-embed-type".into(), "post".into()));
400- }
401- }
402- } else {
403- new_attrs
404- .attrs
405- .push(("data-embed-type".into(), "markdown".into()));
406 }
000407408- Tag::Embed {
0000000000409 embed_type: *embed_type,
410- dest_url: dest_url.clone(),
411 title: title.clone(),
412 id: id.clone(),
413- attrs: Some(new_attrs),
414- }
000000000000000000415 } else {
416- // Fetch failed, return original
417- embed
418 }
00419 }
420- _ => embed,
0000000000000000000421 }
422 }
423···452 #[test]
453 fn test_at_uri_to_web_url_profile() {
454 let uri = AtUri::new("at://did:plc:xyz123").unwrap();
455- assert_eq!(at_uri_to_web_url(&uri), "https://weaver.sh/did:plc:xyz123");
000456 }
457458 #[test]
···496 let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap();
497 assert_eq!(
498 at_uri_to_web_url(&uri),
499- "https://weaver.sh/did:plc:xyz123/sh.weaver.notebook.entry/entry123"
500 );
501 }
502···505 let uri = AtUri::new("at://did:plc:xyz123/com.example.unknown/rkey").unwrap();
506 assert_eq!(
507 at_uri_to_web_url(&uri),
508- "https://weaver.sh/did:plc:xyz123/com.example.unknown/rkey"
509 );
510 }
511}
···5 prelude::IdentityResolver,
6 types::string::{AtUri, Cid, Did},
7};
8+use markdown_weaver::{CowStr as MdCowStr, LinkType, Tag, WeaverAttributes};
9use std::collections::HashMap;
10use std::sync::Arc;
11use weaver_api::sh_weaver::notebook::entry::Entry;
12+use weaver_common::{EntryIndex, ResolvedContent};
1314/// Trait for resolving embed content on the client side
15///
···53impl<A: AgentSession + IdentityResolver> EmbedResolver for DefaultEmbedResolver<A> {
54 async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> {
55 use crate::atproto::fetch_and_render_profile;
56+ fetch_and_render_profile(uri.authority(), &*self.agent)
000000000000057 .await
58 .map_err(|e| ClientRenderError::EntryFetch {
59 uri: uri.as_ref().to_string(),
···132 embed_resolver: Option<Arc<R>>,
133 embed_depth: usize,
134135+ // Pre-resolved content for sync rendering
136+ entry_index: Option<EntryIndex>,
137+ resolved_content: Option<ResolvedContent>,
138+139 // Shared state
140 frontmatter: Frontmatter,
141 title: MdCowStr<'a>,
···152 blob_map,
153 embed_resolver: None,
154 embed_depth: 0,
155+ entry_index: None,
156+ resolved_content: None,
157 frontmatter: Frontmatter::default(),
158 title,
159 }
···167 blob_map: self.blob_map,
168 embed_resolver: Some(resolver),
169 embed_depth: self.embed_depth,
170+ entry_index: self.entry_index,
171+ resolved_content: self.resolved_content,
172 frontmatter: self.frontmatter,
173 title: self.title,
174 }
175 }
176+177+ /// Add an entry index for wikilink resolution
178+ pub fn with_entry_index(mut self, index: EntryIndex) -> Self {
179+ self.entry_index = Some(index);
180+ self
181+ }
182+183+ /// Add pre-resolved content for sync rendering
184+ pub fn with_resolved_content(mut self, content: ResolvedContent) -> Self {
185+ self.resolved_content = Some(content);
186+ self
187+ }
188}
189190impl<'a, R: EmbedResolver> ClientContext<'a, R> {
···199 blob_map: self.blob_map.clone(),
200 embed_resolver: self.embed_resolver.clone(),
201 embed_depth: depth,
202+ entry_index: self.entry_index.clone(),
203+ resolved_content: self.resolved_content.clone(),
204 frontmatter: self.frontmatter.clone(),
205 title: self.title.clone(),
206 }
207 }
208209+ /// Build an embed tag with resolved content attached
210+ fn build_embed_with_content<'s>(
211+ &self,
212+ embed_type: markdown_weaver::EmbedType,
213+ url: String,
214+ title: MdCowStr<'s>,
215+ id: MdCowStr<'s>,
216+ content: String,
217+ is_at_uri: bool,
218+ ) -> Tag<'s> {
219+ let mut attrs = WeaverAttributes {
220+ classes: vec![],
221+ attrs: vec![],
222+ };
223+224+ attrs.attrs.push(("content".into(), content.into()));
225+226+ // Add metadata for client-side enhancement
227+ if is_at_uri {
228+ attrs
229+ .attrs
230+ .push(("data-embed-uri".into(), url.clone().into()));
231+232+ if let Ok(at_uri) = AtUri::new(&url) {
233+ if at_uri.collection().is_none() {
234+ attrs
235+ .attrs
236+ .push(("data-embed-type".into(), "profile".into()));
237+ } else {
238+ attrs.attrs.push(("data-embed-type".into(), "post".into()));
239+ }
240+ }
241+ }
242+243+ Tag::Embed {
244+ embed_type,
245+ dest_url: MdCowStr::Boxed(url.into_boxed_str()),
246+ title,
247+ id,
248+ attrs: Some(attrs),
249+ }
250+ }
251+252 fn build_blob_map<'b>(entry: &Entry<'b>) -> HashMap<BlobName<'static>, Cid<'static>> {
253 use jacquard::IntoStatic;
254···348 title,
349 id,
350 } => {
351+ // Handle WikiLinks via EntryIndex
352+ if matches!(link_type, LinkType::WikiLink { .. }) {
353+ if let Some(index) = &self.entry_index {
354+ let url = dest_url.as_ref();
355+ if let Some((path, _title, fragment)) = index.resolve(url) {
356+ // Build resolved URL with optional fragment
357+ let resolved_url = match fragment {
358+ Some(frag) => format!("{}#{}", path, frag),
359+ None => path.to_string(),
360+ };
361+362+ return Tag::Link {
363+ link_type: *link_type,
364+ dest_url: MdCowStr::Boxed(resolved_url.into_boxed_str()),
365+ title: title.clone(),
366+ id: id.clone(),
367+ };
368+ }
369+ }
370+ // Unresolved wikilink - render as broken link
371+ return Tag::Link {
372+ link_type: *link_type,
373+ dest_url: MdCowStr::Boxed(format!("#{}", dest_url).into_boxed_str()),
374+ title: title.clone(),
375+ id: id.clone(),
376+ };
377+ }
378+379 let url = dest_url.as_ref();
380381 // Try to parse as AT URI
···405 }
406407 async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> {
408+ let Tag::Embed {
409+ embed_type,
410+ dest_url,
411+ title,
412+ id,
413+ attrs,
414+ } = &embed
415+ else {
416+ return embed;
417+ };
00000000000000000000000000000000000000418419+ // If content already in attrs (from preprocessor), pass through
420+ if let Some(attrs) = attrs {
421+ if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") {
422+ return embed;
423+ }
424+ }
425426+ // Own the URL to avoid borrow issues
427+ let url: String = dest_url.to_string();
428429+ // Check recursion depth
430+ if self.embed_depth >= MAX_EMBED_DEPTH {
431+ return embed;
432+ }
0433434+ // First check for pre-resolved AT URI content
435+ if url.starts_with("at://") {
436+ if let Ok(at_uri) = AtUri::new(&url) {
437+ if let Some(resolved) = &self.resolved_content {
438+ if let Some(content) = resolved.get_embed_content(&at_uri) {
439+ return self.build_embed_with_content(
440+ *embed_type,
441+ url.clone(),
442+ title.clone(),
443+ id.clone(),
444+ content.to_string(),
445+ true,
446+ );
00447 }
448+ }
449+ }
450+ }
451452+ // Check for wikilink-style embed (![[Entry Name]]) via entry index
453+ if !url.starts_with("at://") && !url.starts_with("http://") && !url.starts_with("https://")
454+ {
455+ if let Some(index) = &self.entry_index {
456+ if let Some((path, _title, fragment)) = index.resolve(&url) {
457+ // Entry embed - link to the entry
458+ let resolved_url = match fragment {
459+ Some(frag) => format!("{}#{}", path, frag),
460+ None => path.to_string(),
461+ };
462+ return Tag::Embed {
463 embed_type: *embed_type,
464+ dest_url: MdCowStr::Boxed(resolved_url.into_boxed_str()),
465 title: title.clone(),
466 id: id.clone(),
467+ attrs: attrs.clone(),
468+ };
469+ }
470+ }
471+ // Unresolved entry embed - pass through
472+ return embed;
473+ }
474+475+ // Fallback to async resolver if available
476+ let Some(resolver) = &self.embed_resolver else {
477+ return embed;
478+ };
479+480+ // Try to fetch content based on URL type
481+ let content_result = if url.starts_with("at://") {
482+ // AT Protocol embed
483+ if let Ok(at_uri) = AtUri::new(&url) {
484+ if at_uri.collection().is_none() && at_uri.rkey().is_none() {
485+ // Profile embed
486+ resolver.resolve_profile(&at_uri).await
487 } else {
488+ // Post/record embed
489+ resolver.resolve_post(&at_uri).await
490 }
491+ } else {
492+ return embed;
493 }
494+ } else if url.starts_with("http://") || url.starts_with("https://") {
495+ // Markdown embed
496+ resolver.resolve_markdown(&url, self.embed_depth + 1).await
497+ } else {
498+ return embed;
499+ };
500+501+ // If we got content, attach it
502+ if let Ok(content) = content_result {
503+ let is_at = url.starts_with("at://");
504+ self.build_embed_with_content(
505+ *embed_type,
506+ url,
507+ title.clone(),
508+ id.clone(),
509+ content,
510+ is_at,
511+ )
512+ } else {
513+ embed
514 }
515 }
516···545 #[test]
546 fn test_at_uri_to_web_url_profile() {
547 let uri = AtUri::new("at://did:plc:xyz123").unwrap();
548+ assert_eq!(
549+ at_uri_to_web_url(&uri),
550+ "https://alpha.weaver.sh/did:plc:xyz123"
551+ );
552 }
553554 #[test]
···592 let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap();
593 assert_eq!(
594 at_uri_to_web_url(&uri),
595+ "https://alpha.weaver.sh/record/at://did:plc:xyz123/sh.weaver.notebook.entry/entry123"
596 );
597 }
598···601 let uri = AtUri::new("at://did:plc:xyz123/com.example.unknown/rkey").unwrap();
602 assert_eq!(
603 at_uri_to_web_url(&uri),
604+ "https://alpha.weaver.sh/record/at://did:plc:xyz123/com.example.unknown/rkey"
605 );
606 }
607}
···27pub mod base_html;
28pub mod code_pretty;
29pub mod css;
030#[cfg(not(target_family = "wasm"))]
31pub mod static_site;
32pub mod theme;
···27pub mod base_html;
28pub mod code_pretty;
29pub mod css;
30+pub mod math;
31#[cfg(not(target_family = "wasm"))]
32pub mod static_site;
33pub mod theme;