···5959/// - `initial_content`: Optional initial markdown content (for new entries)
6060/// - `entry_uri`: Optional AT-URI of an existing entry to edit
6161/// - `target_notebook`: Optional notebook title to add the entry to when publishing
6262+/// - `entry_index`: Optional index of entries for wikilink validation
6263#[component]
6364pub fn MarkdownEditor(
6465 initial_content: Option<String>,
6566 entry_uri: Option<String>,
6667 target_notebook: Option<SmolStr>,
6868+ entry_index: Option<weaver_common::EntryIndex>,
6769) -> Element {
6870 let fetcher = use_context::<Fetcher>();
69717070- // Determine draft key - use entry URI if editing existing, otherwise generate TID
7172 let draft_key = use_hook(|| {
7273 entry_uri.clone().unwrap_or_else(|| {
7374 format!(
···7778 })
7879 });
79808080- // Parse entry URI once
8181 let parsed_uri = entry_uri.as_ref().and_then(|s| {
8282 jacquard::types::string::AtUri::new(s)
8383 .ok()
8484 .map(|u| u.into_static())
8585 });
8686-8787- // Clone draft_key for render (resource closure moves it)
8886 let draft_key_for_render = draft_key.clone();
8787+ let target_notebook_for_render = target_notebook.clone();
89889090- // Resource loads and merges document state
9189 let load_resource = use_resource(move || {
9290 let fetcher = fetcher.clone();
9391 let draft_key = draft_key.clone();
···9593 let initial_content = initial_content.clone();
96949795 async move {
9898- // Try to load merged state from PDS + localStorage
9996 match load_and_merge_document(&fetcher, &draft_key, entry_uri.as_ref()).await {
10097 Ok(Some(state)) => {
10198 tracing::debug!("Loaded merged document state");
···110107 let is_own_entry = match entry_authority {
111108 AtIdentifier::Did(did) => did == ¤t_did,
112109 AtIdentifier::Handle(handle) => {
113113- // Resolve handle to DID and compare
114110 match fetcher.client.resolve_handle(handle).await {
115111 Ok(resolved_did) => resolved_did == current_did,
116112 Err(_) => false,
···127123 );
128124 }
129125 }
130130-131131- // Try to load the entry content from PDS
132126 match load_entry_for_editing(&fetcher, uri).await {
133127 Ok(loaded) => {
134128 // Create LoadedDocState from entry
···188182 }
189183 });
190184191191- // Render based on load state
192185 match &*load_resource.read() {
193186 Some(LoadResult::Loaded(state)) => {
194187 rsx! {
···196189 key: "{draft_key_for_render}",
197190 draft_key: draft_key_for_render.clone(),
198191 loaded_state: state.clone(),
192192+ target_notebook: target_notebook_for_render.clone(),
193193+ entry_index: entry_index.clone(),
199194 }
200195 }
201196 }
···226221/// - PDS sync with auto-save
227222/// - Keyboard shortcuts (Ctrl+B for bold, Ctrl+I for italic)
228223#[component]
229229-fn MarkdownEditorInner(draft_key: String, loaded_state: LoadedDocState) -> Element {
224224+fn MarkdownEditorInner(
225225+ draft_key: String,
226226+ loaded_state: LoadedDocState,
227227+ target_notebook: Option<SmolStr>,
228228+ /// Optional entry index for wikilink validation in the editor
229229+ entry_index: Option<weaver_common::EntryIndex>,
230230+) -> Element {
230231 // Context for authenticated API calls
231232 let fetcher = use_context::<Fetcher>();
232233 let auth_state = use_context::<Signal<AuthState>>();
233234234234- // Create EditorDocument from loaded state (must be in use_hook for Signals)
235235 let mut document = use_hook(|| {
236236 let doc = EditorDocument::from_loaded_state(loaded_state.clone());
237237- // Save to localStorage so we have a local backup
238237 storage::save_to_storage(&doc, &draft_key).ok();
239238 doc
240239 });
241240 let editor_id = "markdown-editor";
242242-243243- // Cache for incremental paragraph rendering
244241 let mut render_cache = use_signal(|| render::RenderCache::default());
245245-246246- // Image resolver for mapping /image/{name} to data URLs or CDN URLs
247242 let mut image_resolver = use_signal(EditorImageResolver::default);
243243+ let resolved_content = use_signal(weaver_common::ResolvedContent::default);
248244249249- // Render paragraphs with incremental caching
250250- // Reads document.last_edit signal - creates dependency on content changes only
251245 let doc_for_memo = document.clone();
246246+ let doc_for_refs = document.clone();
252247 let paragraphs = use_memo(move || {
253253- let edit = doc_for_memo.last_edit(); // Signal read - reactive dependency
248248+ let edit = doc_for_memo.last_edit();
254249 let cache = render_cache.peek();
255250 let resolver = image_resolver();
251251+ let resolved = resolved_content();
256252257257- let (paras, new_cache) = render::render_paragraphs_incremental(
253253+ let (paras, new_cache, refs) = render::render_paragraphs_incremental(
258254 doc_for_memo.loro_text(),
259255 Some(&cache),
260256 edit.as_ref(),
261257 Some(&resolver),
258258+ entry_index.as_ref(),
259259+ &resolved,
262260 );
263263-264264- // Update cache for next render (write-only via spawn to avoid reactive loop)
261261+ let mut doc_for_spawn = doc_for_refs.clone();
265262 dioxus::prelude::spawn(async move {
266263 render_cache.set(new_cache);
264264+ doc_for_spawn.set_collected_refs(refs);
267265 });
268266269267 paras
270268 });
271269272272- // Flatten offset maps from all paragraphs
270270+ // Background fetch for AT embeds
271271+ let mut resolved_content_for_fetch = resolved_content.clone();
272272+ let doc_for_embeds = document.clone();
273273+ let fetcher_for_embeds = fetcher.clone();
274274+ use_effect(move || {
275275+ let refs = doc_for_embeds.collected_refs.read();
276276+ let current_resolved = resolved_content_for_fetch.peek();
277277+ let fetcher = fetcher_for_embeds.clone();
278278+279279+ // Find AT embeds that need fetching
280280+ let to_fetch: Vec<String> = refs
281281+ .iter()
282282+ .filter_map(|r| match r {
283283+ weaver_common::ExtractedRef::AtEmbed { uri, .. } => {
284284+ // Skip if already resolved
285285+ if let Ok(at_uri) = jacquard::types::string::AtUri::new(uri) {
286286+ if current_resolved.get_embed_content(&at_uri).is_none() {
287287+ return Some(uri.clone());
288288+ }
289289+ }
290290+ None
291291+ }
292292+ _ => None,
293293+ })
294294+ .collect();
295295+296296+ if to_fetch.is_empty() {
297297+ return;
298298+ }
299299+300300+ // Spawn background fetches
301301+ dioxus::prelude::spawn(async move {
302302+ for uri_str in to_fetch {
303303+ let Ok(at_uri) = jacquard::types::string::AtUri::new(&uri_str) else {
304304+ continue;
305305+ };
306306+307307+ match weaver_renderer::atproto::fetch_and_render(&at_uri, &fetcher)
308308+ .await
309309+ {
310310+ Ok(html) => {
311311+ resolved_content_for_fetch.with_mut(|rc| {
312312+ rc.add_embed(at_uri.into_static(), html, None);
313313+ });
314314+ }
315315+ Err(e) => {
316316+ tracing::warn!("failed to fetch embed {}: {}", uri_str, e);
317317+ }
318318+ }
319319+ }
320320+ });
321321+ });
322322+323323+ let mut new_tag = use_signal(String::new);
324324+273325 let offset_map = use_memo(move || {
274326 paragraphs()
275327 .iter()
276328 .flat_map(|p| p.offset_map.iter().cloned())
277329 .collect::<Vec<_>>()
278330 });
279279-280280- // Flatten syntax spans from all paragraphs
281331 let syntax_spans = use_memo(move || {
282332 paragraphs()
283333 .iter()
284334 .flat_map(|p| p.syntax_spans.iter().cloned())
285335 .collect::<Vec<_>>()
286336 });
287287-288288- // Cache paragraphs for change detection AND for event handlers to access
289337 let mut cached_paragraphs = use_signal(|| Vec::<ParagraphRender>::new());
290338291291- // Update DOM when paragraphs change (incremental rendering)
292339 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
293340 let mut doc_for_dom = document.clone();
294341 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···365412 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
366413 let mut interval_holder: Signal<Option<gloo_timers::callback::Interval>> = use_signal(|| None);
367414368368- // Auto-save with periodic check (no reactive dependency to avoid loops)
369415 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
370416 let doc_for_autosave = document.clone();
371417 #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
···385431 None => true,
386432 Some(last) => ¤t_frontiers != last,
387433 }
388388- }; // drop last_frontiers borrow here
434434+ };
389435390436 if needs_save {
391437 doc.sync_loro_cursor();
···436482 // Get target range from the event if available
437483 let paras = cached_paras.peek().clone();
438484 let target_range = get_target_range_from_event(&evt, editor_id, ¶s);
439439-440440- // Get data from the event
441485 let data = get_data_from_event(&evt);
442442-443443- // Build context and handle
444486 let ctx = BeforeInputContext {
445487 input_type: input_type.clone(),
446488 data,
···530572 closure.forget();
531573 });
532574533533- // Local state for adding new tags
534534- let mut new_tag = use_signal(String::new);
535535-536575 rsx! {
537576 Stylesheet { href: asset!("/assets/styling/editor.css") }
538577 div { class: "markdown-editor-container",
···621660 PublishButton {
622661 document: document.clone(),
623662 draft_key: draft_key.to_string(),
663663+ target_notebook: target_notebook.as_ref().map(|s| s.to_string()),
624664 }
625665 }
626666 }
···804844805845 onclick: {
806846 let mut doc = document.clone();
807807- move |_evt| {
847847+ move |evt| {
808848 tracing::debug!("onclick fired");
809849 let paras = cached_paragraphs();
850850+851851+ // Check if click target is a math-clickable element
852852+ #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
853853+ {
854854+ use dioxus::web::WebEventExt;
855855+ use wasm_bindgen::JsCast;
856856+857857+ let web_evt = evt.as_web_event();
858858+ if let Some(target) = web_evt.target() {
859859+ if let Some(element) = target.dyn_ref::<web_sys::Element>() {
860860+ // Check element or ancestors for math-clickable
861861+ if let Ok(Some(math_el)) = element.closest(".math-clickable") {
862862+ if let Some(char_target) = math_el.get_attribute("data-char-target") {
863863+ if let Ok(offset) = char_target.parse::<usize>() {
864864+ tracing::debug!("math-clickable clicked, moving cursor to {}", offset);
865865+ doc.cursor.write().offset = offset;
866866+ *doc.selection.write() = None;
867867+ // Update visibility FIRST so math-source is visible
868868+ let spans = syntax_spans();
869869+ update_syntax_visibility(offset, None, &spans, ¶s);
870870+ // Then set DOM selection
871871+ let map = offset_map();
872872+ let _ = crate::components::editor::cursor::restore_cursor_position(
873873+ offset,
874874+ &map,
875875+ editor_id,
876876+ None,
877877+ );
878878+ return;
879879+ }
880880+ }
881881+ }
882882+ }
883883+ }
884884+ }
885885+810886 sync_cursor_from_dom(&mut doc, editor_id, ¶s);
811887 let spans = syntax_spans();
812888 let cursor_offset = doc.cursor.read().offset;
···9801056 }
9811057 },
9821058 }
983983-984984- // Debug panel snug below editor
9851059 div { class: "editor-debug",
9861060 div { "Cursor: {document.cursor.read().offset}, Chars: {document.len_chars()}" },
9871061 ReportButton {
···9911065 }
9921066 }
9931067994994- // Toolbar in grid column 2, row 3
9951068 EditorToolbar {
9961069 on_format: {
9971070 let mut doc = document.clone();
···10401113 spawn(async move {
10411114 let client = fetcher.get_client();
1042111511161116+ // Clone data for cache pre-warming
11171117+ let data_for_cache = data.clone();
11181118+10431119 // Upload blob and create temporary PublishedBlob record
10441120 match client.publish_blob(data, &name_for_upload, None).await {
10451121 Ok((strong_ref, published_blob)) => {
···10611137 }
10621138 };
1063113910641064- // Build Image using the builder API
11401140+ let cid = published_blob.upload.blob().cid().clone().into_static();
11411141+10651142 let name_for_resolver = name_for_upload.clone();
10661143 let image = Image::new()
10671144 .alt(alt_for_upload.to_cowstr())
10681145 .image(published_blob.upload)
10691146 .name(name_for_upload.to_cowstr())
10701147 .build();
10711071-10721072- // Add to document
10731148 doc_for_spawn.add_image(&image, Some(&strong_ref.uri));
1074114910751150 // Promote from pending to uploaded in resolver
···10831158 });
1084115910851160 tracing::info!(name = %name_for_resolver, "Image uploaded to PDS");
11611161+11621162+ // Pre-warm server cache with blob bytes
11631163+ #[cfg(feature = "fullstack-server")]
11641164+ {
11651165+ use jacquard::smol_str::ToSmolStr;
11661166+ if let Err(e) = crate::data::cache_blob_bytes(
11671167+ cid.to_smolstr(),
11681168+ Some(name_for_resolver.into()),
11691169+ None,
11701170+ data_for_cache.into(),
11711171+ ).await {
11721172+ tracing::warn!(error = %e, "Failed to pre-warm blob cache");
11731173+ }
11741174+ }
10861175 }
10871176 Err(e) => {
10881177 tracing::error!(error = %e, "Failed to upload image");
···10911180 }
10921181 });
10931182 } else {
10941094- tracing::info!(name = %name, "Image added with data URL (not authenticated)");
11831183+ tracing::debug!(name = %name, "Image added with data URL (not authenticated)");
10951184 }
10961185 }
10971186 },
+31-3
crates/weaver-app/src/components/editor/cursor.rs
···115115///
116116/// Walks all text nodes in the container, accumulating their UTF-16 lengths
117117/// until we find the node containing the target offset.
118118+/// Skips text nodes inside contenteditable="false" elements (like embeds).
118119///
119120/// Returns (text_node, offset_within_node).
120121#[cfg(all(target_family = "wasm", target_os = "unknown"))]
···127128 .document()
128129 .ok_or("no document")?;
129130130130- // Create tree walker to find text nodes
131131- // SHOW_TEXT = 4 (from DOM spec)
132132- let walker = document.create_tree_walker_with_what_to_show(container, 4)?;
131131+ // Use SHOW_ALL to see element boundaries for tracking non-editable regions
132132+ let walker = document.create_tree_walker_with_what_to_show(container, 0xFFFFFFFF)?;
133133134134 let mut accumulated_utf16 = 0;
135135 let mut last_node: Option<web_sys::Node> = None;
136136+ let mut skip_until_exit: Option<web_sys::Element> = None;
136137137138 while let Some(node) = walker.next_node()? {
139139+ // Check if we've exited the non-editable subtree
140140+ if let Some(ref skip_elem) = skip_until_exit {
141141+ if !skip_elem.contains(Some(&node)) {
142142+ skip_until_exit = None;
143143+ }
144144+ }
145145+146146+ // Check if entering a non-editable element
147147+ if skip_until_exit.is_none() {
148148+ if let Some(element) = node.dyn_ref::<web_sys::Element>() {
149149+ if element.get_attribute("contenteditable").as_deref() == Some("false") {
150150+ skip_until_exit = Some(element.clone());
151151+ continue;
152152+ }
153153+ }
154154+ }
155155+156156+ // Skip everything inside non-editable regions
157157+ if skip_until_exit.is_some() {
158158+ continue;
159159+ }
160160+161161+ // Only process text nodes
162162+ if node.node_type() != web_sys::Node::TEXT_NODE {
163163+ continue;
164164+ }
165165+138166 last_node = Some(node.clone());
139167140168 if let Some(text) = node.text_content() {
···348348 }
349349}
350350351351-//#[cfg(not(feature = "server"))]
352351#[derive(Clone)]
353352pub struct Fetcher {
354353 pub client: Arc<Client>,
···357356 (AtIdentifier<'static>, SmolStr),
358357 Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>,
359358 >,
359359+ /// Maps notebook title OR path to ident (book_cache accepts either as key)
360360+ #[cfg(feature = "server")]
361361+ notebook_key_cache: cache_impl::Cache<SmolStr, AtIdentifier<'static>>,
360362 #[cfg(feature = "server")]
361363 entry_cache: cache_impl::Cache<
362364 (AtIdentifier<'static>, SmolStr),
···369371 cache_impl::Cache<(AtIdentifier<'static>, SmolStr), Arc<StandaloneEntryData>>,
370372}
371373372372-//#[cfg(not(feature = "server"))]
373374impl Fetcher {
374375 pub fn new(client: OAuthClient<JacquardResolver, AuthStore>) -> Self {
375376 Self {
376377 client: Arc::new(Client::new(client)),
377378 #[cfg(feature = "server")]
378379 book_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)),
380380+ #[cfg(feature = "server")]
381381+ notebook_key_cache: cache_impl::new_cache(500, std::time::Duration::from_secs(30)),
379382 #[cfg(feature = "server")]
380383 entry_cache: cache_impl::new_cache(100, std::time::Duration::from_secs(30)),
381384 #[cfg(feature = "server")]
···432435 {
433436 let stored = Arc::new((notebook, entries));
434437 #[cfg(feature = "server")]
435435- cache_impl::insert(&self.book_cache, (ident, title), stored.clone());
438438+ {
439439+ // Cache by title
440440+ cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
441441+ cache_impl::insert(&self.book_cache, (ident.clone(), title), stored.clone());
442442+ // Also cache by path if available
443443+ if let Some(path) = stored.0.path.as_ref() {
444444+ let path: SmolStr = path.as_ref().into();
445445+ cache_impl::insert(&self.notebook_key_cache, path.clone(), ident.clone());
446446+ cache_impl::insert(&self.book_cache, (ident, path), stored.clone());
447447+ }
448448+ }
436449 Ok(Some(stored))
437450 } else {
438451 Err(dioxus::CapturedError::from_display("Notebook not found"))
439452 }
440453 }
441454455455+ /// Get notebook by title or path (for image resolution without knowing owner).
456456+ /// Checks notebook_key_cache first, falls back to UFOS discovery.
457457+ #[cfg(feature = "server")]
458458+ pub async fn get_notebook_by_key(
459459+ &self,
460460+ key: &str,
461461+ ) -> Result<Option<Arc<(NotebookView<'static>, Vec<StrongRef<'static>>)>>> {
462462+ let key: SmolStr = key.into();
463463+464464+ // Check cache first (key could be title or path)
465465+ if let Some(ident) = cache_impl::get(&self.notebook_key_cache, &key) {
466466+ return self.get_notebook(ident, key).await;
467467+ }
468468+469469+ // Fallback: query UFOS and populate caches
470470+ let notebooks = self.fetch_notebooks_from_ufos().await?;
471471+ Ok(notebooks.into_iter().find(|arc| {
472472+ let (view, _) = arc.as_ref();
473473+ view.title.as_deref() == Some(key.as_str())
474474+ || view.path.as_deref() == Some(key.as_str())
475475+ }))
476476+ }
477477+442478 pub async fn get_entry(
443479 &self,
444480 ident: AtIdentifier<'static>,
···509545510546 let result = Arc::new((notebook, entries));
511547 #[cfg(feature = "server")]
512512- cache_impl::insert(&self.book_cache, (ident, title), result.clone());
548548+ {
549549+ // Cache by title
550550+ cache_impl::insert(&self.notebook_key_cache, title.clone(), ident.clone());
551551+ cache_impl::insert(
552552+ &self.book_cache,
553553+ (ident.clone(), title),
554554+ result.clone(),
555555+ );
556556+ // Also cache by path if available
557557+ if let Some(path) = result.0.path.as_ref() {
558558+ let path: SmolStr = path.as_ref().into();
559559+ cache_impl::insert(
560560+ &self.notebook_key_cache,
561561+ path.clone(),
562562+ ident.clone(),
563563+ );
564564+ cache_impl::insert(&self.book_cache, (ident, path), result.clone());
565565+ }
566566+ }
513567 notebooks.push(result);
514568 }
515569 Err(_) => continue, // Skip notebooks that fail to load
···635689636690 let result = Arc::new((notebook, entries));
637691 #[cfg(feature = "server")]
638638- cache_impl::insert(&self.book_cache, (ident, title), result.clone());
692692+ {
693693+ // Cache by title
694694+ cache_impl::insert(
695695+ &self.notebook_key_cache,
696696+ title.clone(),
697697+ ident.clone(),
698698+ );
699699+ cache_impl::insert(
700700+ &self.book_cache,
701701+ (ident.clone(), title),
702702+ result.clone(),
703703+ );
704704+ // Also cache by path if available
705705+ if let Some(path) = result.0.path.as_ref() {
706706+ let path: SmolStr = path.as_ref().into();
707707+ cache_impl::insert(
708708+ &self.notebook_key_cache,
709709+ path.clone(),
710710+ ident.clone(),
711711+ );
712712+ cache_impl::insert(&self.book_cache, (ident, path), result.clone());
713713+ }
714714+ }
639715 notebooks.push(result);
640716 }
641717 Err(_) => continue, // Skip notebooks that fail to load
+10-9
crates/weaver-app/src/main.rs
···173173174174 #[cfg(feature = "fullstack-server")]
175175 let router = {
176176- use jacquard::client::UnauthenticatedSession;
177176 let fetcher = Arc::new(fetch::Fetcher::new(OAuthClient::new(
178177 AuthStore::new(),
179178 ClientData::new_public(CONFIG.oauth.clone()),
180179 )));
181181- let blob_cache = Arc::new(BlobCache::new(Arc::new(
182182- UnauthenticatedSession::new_public(),
183183- )));
180180+ let blob_cache = Arc::new(BlobCache::new(fetcher.clone()));
184181 axum::Router::new()
185182 .route("/favicon.ico", get(favicon))
186183 // Server side render the application, serve static assets, and register server functions
···353350}
354351355352#[cfg(all(feature = "fullstack-server", feature = "server"))]
356356-#[get("/{_notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)]
357357-pub async fn image_named(_notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> {
353353+#[get("/{notebook}/image/{name}", blob_cache: Extension<Arc<crate::blobcache::BlobCache>>)]
354354+pub async fn image_named(notebook: SmolStr, name: SmolStr) -> Result<axum::response::Response> {
358355 if let Some(bytes) = blob_cache.get_named(&name) {
359359- Ok(build_image_response(bytes))
360360- } else {
361361- Ok(image_not_found())
356356+ return Ok(build_image_response(bytes));
357357+ }
358358+359359+ // Try to resolve from notebook
360360+ match blob_cache.resolve_from_notebook(¬ebook, &name).await {
361361+ Ok(bytes) => Ok(build_image_response(bytes)),
362362+ Err(_) => Ok(image_not_found()),
362363 }
363364}
364365
+30-1
crates/weaver-app/src/views/drafts.rs
···208208 rkey: ReadSignal<SmolStr>,
209209) -> Element {
210210 use crate::components::editor::MarkdownEditor;
211211+ use crate::data::use_notebook_entries;
211212 use crate::views::editor::EditorCss;
213213+ use weaver_common::EntryIndex;
212214213215 // Construct AT-URI for the entry
214216 let entry_uri =
215217 use_memo(move || format!("at://{}/sh.weaver.notebook.entry/{}", ident(), rkey()));
216218219219+ // Fetch notebook entries for wikilink validation
220220+ let (_entries_resource, entries_memo) = use_notebook_entries(ident, book_title);
221221+222222+ // Build entry index from notebook entries
223223+ let entry_index = use_memo(move || {
224224+ entries_memo().map(|entries| {
225225+ let mut index = EntryIndex::new();
226226+ let ident_str = ident().to_string();
227227+ let book = book_title();
228228+ for book_entry in &entries {
229229+ // EntryView has optional title/path
230230+ let title = book_entry.entry.title.as_ref().map(|t| t.as_str()).unwrap_or("");
231231+ let path = book_entry.entry.path.as_ref().map(|p| p.as_str()).unwrap_or("");
232232+ if !title.is_empty() || !path.is_empty() {
233233+ // Build canonical URL: /{ident}/{book}/{path}
234234+ let canonical_url = format!("/{}/{}/{}", ident_str, book, path);
235235+ index.add_entry(title, path, canonical_url);
236236+ }
237237+ }
238238+ index
239239+ })
240240+ });
241241+217242 rsx! {
218243 EditorCss {}
219244 div { class: "editor-page",
220220- MarkdownEditor { entry_uri: Some(entry_uri()), target_notebook: Some(book_title()) }
245245+ MarkdownEditor {
246246+ entry_uri: Some(entry_uri()),
247247+ target_notebook: Some(book_title()),
248248+ entry_index: entry_index(),
249249+ }
221250 }
222251 }
223252}
+1-1
crates/weaver-cli/src/main.rs
···389389 use jacquard::http_client::HttpClient;
390390 use weaver_common::WeaverExt;
391391 let (entry_ref, was_created) = agent
392392- .upsert_entry(&title, entry_title.as_ref(), entry)
392392+ .upsert_entry(&title, entry_title.as_ref(), entry, None)
393393 .await?;
394394395395 if was_created {
+55-2
crates/weaver-common/src/agent.rs
···250250 }
251251 }
252252253253- /// Find or create an entry within a notebook by title
253253+ /// Find or create an entry within a notebook
254254 ///
255255 /// Multi-step workflow:
256256 /// 1. Find the notebook by title
257257- /// 2. Check notebook's entry_list for entry with matching title
257257+ /// 2. If existing_rkey is provided, match by rkey; otherwise match by title
258258 /// 3. If found: update the entry with new content
259259 /// 4. If not found: create new entry and append to notebook's entry_list
260260 ///
261261+ /// The `existing_rkey` parameter allows updating an entry even if its title changed,
262262+ /// and enables pre-generating rkeys for path rewriting before publish.
263263+ ///
261264 /// Returns (entry_ref, was_created)
262265 fn upsert_entry(
263266 &self,
264267 notebook_title: &str,
265268 entry_title: &str,
266269 entry: entry::Entry<'_>,
270270+ existing_rkey: Option<&str>,
267271 ) -> impl Future<Output = Result<(StrongRef<'static>, bool), WeaverError>>
268272 where
269273 Self: Sized,
···276280277281 // Find or create notebook
278282 let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?;
283283+284284+ // If we have an existing rkey, try to find and update that specific entry
285285+ if let Some(rkey) = existing_rkey {
286286+ // Check if this entry exists in the notebook by comparing rkeys
287287+ for entry_ref in &entry_refs {
288288+ let ref_rkey = entry_ref.uri.rkey().map(|r| r.0.as_str());
289289+ if ref_rkey == Some(rkey) {
290290+ // Found it - update
291291+ let output = self
292292+ .update_record::<entry::Entry>(&entry_ref.uri, |e| {
293293+ e.content = entry.content.clone();
294294+ e.title = entry.title.clone();
295295+ e.path = entry.path.clone();
296296+ e.embeds = entry.embeds.clone();
297297+ e.tags = entry.tags.clone();
298298+ })
299299+ .await?;
300300+ let updated_ref = StrongRef::new()
301301+ .uri(output.uri.into_static())
302302+ .cid(output.cid.into_static())
303303+ .build();
304304+ return Ok((updated_ref, false));
305305+ }
306306+ }
307307+308308+ // Entry with this rkey not in notebook - create with specific rkey
309309+ let response = self
310310+ .create_record(entry, Some(RecordKey::any(rkey)?))
311311+ .await?;
312312+ let new_ref = StrongRef::new()
313313+ .uri(response.uri.clone().into_static())
314314+ .cid(response.cid.clone().into_static())
315315+ .build();
316316+317317+ use weaver_api::sh_weaver::notebook::book::Book;
318318+ let notebook_entry_ref = StrongRef::new()
319319+ .uri(response.uri.into_static())
320320+ .cid(response.cid.into_static())
321321+ .build();
322322+323323+ self.update_record::<Book>(¬ebook_uri, |book| {
324324+ book.entry_list.push(notebook_entry_ref);
325325+ })
326326+ .await?;
327327+328328+ return Ok((new_ref, true));
329329+ }
330330+331331+ // No existing rkey - use title-based matching (original behavior)
279332280333 // Fast path: if notebook is empty, skip search and create directly
281334 if entry_refs.is_empty() {
+4
crates/weaver-common/src/lib.rs
···33pub mod agent;
44pub mod constellation;
55pub mod error;
66+pub mod resolve;
67pub mod worker_rt;
7889// Re-export jacquard for convenience
910pub use agent::WeaverExt;
1011pub use error::WeaverError;
1212+pub use resolve::{EntryIndex, ExtractedRef, RefCollector, ResolvedContent, ResolvedEntry};
1313+#[cfg(any(test, feature = "standalone-collection"))]
1414+pub use resolve::collect_refs_from_markdown;
1115pub use jacquard;
1216use jacquard::CowStr;
1317use jacquard::client::{Agent, AgentSession};
+444
crates/weaver-common/src/resolve.rs
···11+//! Wikilink and embed resolution types for rendering without network calls
22+//!
33+//! This module provides pre-resolution infrastructure so that markdown rendering
44+//! can happen synchronously without network calls in the hot path.
55+66+use std::collections::HashMap;
77+88+use jacquard::CowStr;
99+use jacquard::smol_str::SmolStr;
1010+use jacquard::types::string::AtUri;
1111+use weaver_api::com_atproto::repo::strong_ref::StrongRef;
1212+1313+/// Pre-resolved data for rendering without network calls.
1414+///
1515+/// Populated during an async collection phase, then passed to the sync render phase.
1616+#[derive(Debug, Clone, Default)]
1717+pub struct ResolvedContent {
1818+ /// Wikilink target (lowercase) → resolved entry info
1919+ pub entry_links: HashMap<SmolStr, ResolvedEntry>,
2020+ /// AT URI → rendered HTML content
2121+ pub embed_content: HashMap<AtUri<'static>, CowStr<'static>>,
2222+ /// AT URI → StrongRef for populating records array
2323+ pub embed_refs: Vec<StrongRef<'static>>,
2424+}
2525+2626+/// A resolved entry reference from a wikilink
2727+#[derive(Debug, Clone)]
2828+pub struct ResolvedEntry {
2929+ /// The canonical URL path (e.g., "/handle/notebook/entry_path")
3030+ pub canonical_path: CowStr<'static>,
3131+ /// The original entry title for display
3232+ pub display_title: CowStr<'static>,
3333+}
3434+3535+impl ResolvedContent {
3636+ pub fn new() -> Self {
3737+ Self::default()
3838+ }
3939+4040+ /// Look up a wikilink target, returns the resolved entry if found
4141+ pub fn resolve_wikilink(&self, target: &str) -> Option<&ResolvedEntry> {
4242+ // Strip fragment if present
4343+ let (target, _fragment) = target.split_once('#').unwrap_or((target, ""));
4444+ let key = SmolStr::new(target.to_lowercase());
4545+ self.entry_links.get(&key)
4646+ }
4747+4848+ /// Get pre-rendered embed content for an AT URI
4949+ pub fn get_embed_content(&self, uri: &AtUri<'_>) -> Option<&str> {
5050+ // Need to look up by equivalent URI, not exact reference
5151+ self.embed_content
5252+ .iter()
5353+ .find(|(k, _)| k.as_str() == uri.as_str())
5454+ .map(|(_, v)| v.as_ref())
5555+ }
5656+5757+ /// Add a resolved entry link
5858+ pub fn add_entry(
5959+ &mut self,
6060+ target: &str,
6161+ canonical_path: impl Into<CowStr<'static>>,
6262+ display_title: impl Into<CowStr<'static>>,
6363+ ) {
6464+ self.entry_links.insert(
6565+ SmolStr::new(target.to_lowercase()),
6666+ ResolvedEntry {
6767+ canonical_path: canonical_path.into(),
6868+ display_title: display_title.into(),
6969+ },
7070+ );
7171+ }
7272+7373+ /// Add resolved embed content
7474+ pub fn add_embed(
7575+ &mut self,
7676+ uri: AtUri<'static>,
7777+ html: impl Into<CowStr<'static>>,
7878+ strong_ref: Option<StrongRef<'static>>,
7979+ ) {
8080+ self.embed_content.insert(uri, html.into());
8181+ if let Some(sr) = strong_ref {
8282+ self.embed_refs.push(sr);
8383+ }
8484+ }
8585+}
8686+8787+/// Index of entries within a notebook for wikilink resolution.
8888+///
8989+/// Supports case-insensitive matching against entry title OR path slug.
9090+#[derive(Debug, Clone, Default, PartialEq)]
9191+pub struct EntryIndex {
9292+ /// lowercase title → (canonical_path, original_title)
9393+ by_title: HashMap<SmolStr, (CowStr<'static>, CowStr<'static>)>,
9494+ /// lowercase path slug → (canonical_path, original_title)
9595+ by_path: HashMap<SmolStr, (CowStr<'static>, CowStr<'static>)>,
9696+}
9797+9898+impl EntryIndex {
9999+ pub fn new() -> Self {
100100+ Self::default()
101101+ }
102102+103103+ /// Add an entry to the index
104104+ pub fn add_entry(
105105+ &mut self,
106106+ title: &str,
107107+ path: &str,
108108+ canonical_url: impl Into<CowStr<'static>>,
109109+ ) {
110110+ let canonical: CowStr<'static> = canonical_url.into();
111111+ let title_cow: CowStr<'static> = CowStr::from(title.to_string());
112112+113113+ self.by_title.insert(
114114+ SmolStr::new(title.to_lowercase()),
115115+ (canonical.clone(), title_cow.clone()),
116116+ );
117117+ self.by_path
118118+ .insert(SmolStr::new(path.to_lowercase()), (canonical, title_cow));
119119+ }
120120+121121+ /// Resolve a wikilink target to (canonical_path, display_title, fragment)
122122+ ///
123123+ /// Matches case-insensitively against title first, then path slug.
124124+ /// Fragment (if present) is returned with the input's lifetime.
125125+ pub fn resolve<'a, 'b>(
126126+ &'a self,
127127+ wikilink: &'b str,
128128+ ) -> Option<(&'a str, &'a str, Option<&'b str>)> {
129129+ let (target, fragment) = match wikilink.split_once('#') {
130130+ Some((t, f)) => (t, Some(f)),
131131+ None => (wikilink, None),
132132+ };
133133+ let key = SmolStr::new(target.to_lowercase());
134134+135135+ // Try title match first
136136+ if let Some((path, title)) = self.by_title.get(&key) {
137137+ return Some((path.as_ref(), title.as_ref(), fragment));
138138+ }
139139+140140+ // Try path match
141141+ if let Some((path, title)) = self.by_path.get(&key) {
142142+ return Some((path.as_ref(), title.as_ref(), fragment));
143143+ }
144144+145145+ None
146146+ }
147147+148148+ /// Parse a wikilink into (target, fragment)
149149+ pub fn parse_wikilink(wikilink: &str) -> (&str, Option<&str>) {
150150+ match wikilink.split_once('#') {
151151+ Some((t, f)) => (t, Some(f)),
152152+ None => (wikilink, None),
153153+ }
154154+ }
155155+156156+ /// Check if the index contains any entries
157157+ pub fn is_empty(&self) -> bool {
158158+ self.by_title.is_empty()
159159+ }
160160+161161+ /// Get the number of entries
162162+ pub fn len(&self) -> usize {
163163+ self.by_title.len()
164164+ }
165165+}
166166+167167+/// Reference extracted from markdown that needs resolution
168168+#[derive(Debug, Clone, PartialEq)]
169169+pub enum ExtractedRef {
170170+ /// Wikilink like [[Entry Name]] or [[Entry Name#header]]
171171+ Wikilink {
172172+ target: String,
173173+ fragment: Option<String>,
174174+ display_text: Option<String>,
175175+ },
176176+ /// AT Protocol embed like ![[at://did/collection/rkey]] or 
177177+ AtEmbed {
178178+ uri: String,
179179+ alt_text: Option<String>,
180180+ },
181181+ /// AT Protocol link like [text](at://...)
182182+ AtLink { uri: String },
183183+}
184184+185185+/// Collector for refs encountered during rendering.
186186+///
187187+/// Pass this to renderers to collect refs as a side effect of the render pass.
188188+/// This avoids a separate parsing pass just for collection.
189189+#[derive(Debug, Clone, Default)]
190190+pub struct RefCollector {
191191+ pub refs: Vec<ExtractedRef>,
192192+}
193193+194194+impl RefCollector {
195195+ pub fn new() -> Self {
196196+ Self::default()
197197+ }
198198+199199+ /// Record a wikilink reference
200200+ pub fn add_wikilink(
201201+ &mut self,
202202+ target: &str,
203203+ fragment: Option<&str>,
204204+ display_text: Option<&str>,
205205+ ) {
206206+ self.refs.push(ExtractedRef::Wikilink {
207207+ target: target.to_string(),
208208+ fragment: fragment.map(|s| s.to_string()),
209209+ display_text: display_text.map(|s| s.to_string()),
210210+ });
211211+ }
212212+213213+ /// Record an AT Protocol embed reference
214214+ pub fn add_at_embed(&mut self, uri: &str, alt_text: Option<&str>) {
215215+ self.refs.push(ExtractedRef::AtEmbed {
216216+ uri: uri.to_string(),
217217+ alt_text: alt_text.map(|s| s.to_string()),
218218+ });
219219+ }
220220+221221+ /// Record an AT Protocol link reference
222222+ pub fn add_at_link(&mut self, uri: &str) {
223223+ self.refs.push(ExtractedRef::AtLink {
224224+ uri: uri.to_string(),
225225+ });
226226+ }
227227+228228+ /// Get wikilinks that need resolution
229229+ pub fn wikilinks(&self) -> impl Iterator<Item = &str> {
230230+ self.refs.iter().filter_map(|r| match r {
231231+ ExtractedRef::Wikilink { target, .. } => Some(target.as_str()),
232232+ _ => None,
233233+ })
234234+ }
235235+236236+ /// Get AT URIs that need fetching
237237+ pub fn at_uris(&self) -> impl Iterator<Item = &str> {
238238+ self.refs.iter().filter_map(|r| match r {
239239+ ExtractedRef::AtEmbed { uri, .. } | ExtractedRef::AtLink { uri } => Some(uri.as_str()),
240240+ _ => None,
241241+ })
242242+ }
243243+244244+ /// Take ownership of collected refs
245245+ pub fn take(self) -> Vec<ExtractedRef> {
246246+ self.refs
247247+ }
248248+}
249249+250250+/// Extract all references from markdown that need resolution.
251251+///
252252+/// **Note:** This does a separate parsing pass. For production use, prefer
253253+/// passing a `RefCollector` to the renderer to collect during the render pass.
254254+/// This function is primarily useful for testing or quick analysis.
255255+#[cfg(any(test, feature = "standalone-collection"))]
256256+pub fn collect_refs_from_markdown(markdown: &str) -> Vec<ExtractedRef> {
257257+ use markdown_weaver::{Event, LinkType, Options, Parser, Tag};
258258+259259+ let mut collector = RefCollector::new();
260260+ let options = Options::all();
261261+ let parser = Parser::new_ext(markdown, options);
262262+263263+ for event in parser {
264264+ match event {
265265+ Event::Start(Tag::Link {
266266+ link_type,
267267+ dest_url,
268268+ ..
269269+ }) => {
270270+ let url = dest_url.as_ref();
271271+272272+ if matches!(link_type, LinkType::WikiLink { .. }) {
273273+ let (target, fragment) = match url.split_once('#') {
274274+ Some((t, f)) => (t, Some(f)),
275275+ None => (url, None),
276276+ };
277277+ collector.add_wikilink(target, fragment, None);
278278+ } else if url.starts_with("at://") {
279279+ collector.add_at_link(url);
280280+ }
281281+ }
282282+ Event::Start(Tag::Embed {
283283+ dest_url, title, ..
284284+ }) => {
285285+ let url = dest_url.as_ref();
286286+287287+ if url.starts_with("at://") || url.starts_with("did:") {
288288+ let alt = if title.is_empty() {
289289+ None
290290+ } else {
291291+ Some(title.as_ref())
292292+ };
293293+ collector.add_at_embed(url, alt);
294294+ } else if !url.starts_with("http://") && !url.starts_with("https://") {
295295+ let (target, fragment) = match url.split_once('#') {
296296+ Some((t, f)) => (t, Some(f)),
297297+ None => (url, None),
298298+ };
299299+ collector.add_wikilink(target, fragment, None);
300300+ }
301301+ }
302302+ Event::Start(Tag::Image {
303303+ dest_url, title, ..
304304+ }) => {
305305+ let url = dest_url.as_ref();
306306+307307+ if url.starts_with("at://") {
308308+ let alt = if title.is_empty() {
309309+ None
310310+ } else {
311311+ Some(title.as_ref())
312312+ };
313313+ collector.add_at_embed(url, alt);
314314+ }
315315+ }
316316+ _ => {}
317317+ }
318318+ }
319319+320320+ collector.take()
321321+}
322322+323323+#[cfg(test)]
324324+mod tests {
325325+ use super::*;
326326+ use jacquard::IntoStatic;
327327+328328+ #[test]
329329+ fn test_entry_index_resolve_by_title() {
330330+ let mut index = EntryIndex::new();
331331+ index.add_entry(
332332+ "My First Note",
333333+ "my_first_note",
334334+ "/alice/notebook/my_first_note",
335335+ );
336336+337337+ let result = index.resolve("My First Note");
338338+ assert!(result.is_some());
339339+ let (path, title, fragment) = result.unwrap();
340340+ assert_eq!(path, "/alice/notebook/my_first_note");
341341+ assert_eq!(title, "My First Note");
342342+ assert_eq!(fragment, None);
343343+ }
344344+345345+ #[test]
346346+ fn test_entry_index_resolve_case_insensitive() {
347347+ let mut index = EntryIndex::new();
348348+ index.add_entry(
349349+ "My First Note",
350350+ "my_first_note",
351351+ "/alice/notebook/my_first_note",
352352+ );
353353+354354+ let result = index.resolve("my first note");
355355+ assert!(result.is_some());
356356+ }
357357+358358+ #[test]
359359+ fn test_entry_index_resolve_by_path() {
360360+ let mut index = EntryIndex::new();
361361+ index.add_entry(
362362+ "My First Note",
363363+ "my_first_note",
364364+ "/alice/notebook/my_first_note",
365365+ );
366366+367367+ let result = index.resolve("my_first_note");
368368+ assert!(result.is_some());
369369+ }
370370+371371+ #[test]
372372+ fn test_entry_index_resolve_with_fragment() {
373373+ let mut index = EntryIndex::new();
374374+ index.add_entry("My Note", "my_note", "/alice/notebook/my_note");
375375+376376+ let result = index.resolve("My Note#section");
377377+ assert!(result.is_some());
378378+ let (path, title, fragment) = result.unwrap();
379379+ assert_eq!(path, "/alice/notebook/my_note");
380380+ assert_eq!(title, "My Note");
381381+ assert_eq!(fragment, Some("section"));
382382+ }
383383+384384+ #[test]
385385+ fn test_collect_refs_wikilink() {
386386+ let markdown = "Check out [[My Note]] for more info.";
387387+ let refs = collect_refs_from_markdown(markdown);
388388+389389+ assert_eq!(refs.len(), 1);
390390+ assert!(matches!(
391391+ &refs[0],
392392+ ExtractedRef::Wikilink { target, .. } if target == "My Note"
393393+ ));
394394+ }
395395+396396+ #[test]
397397+ fn test_collect_refs_at_link() {
398398+ let markdown = "See [this post](at://did:plc:xyz/app.bsky.feed.post/abc)";
399399+ let refs = collect_refs_from_markdown(markdown);
400400+401401+ assert_eq!(refs.len(), 1);
402402+ assert!(matches!(
403403+ &refs[0],
404404+ ExtractedRef::AtLink { uri } if uri == "at://did:plc:xyz/app.bsky.feed.post/abc"
405405+ ));
406406+ }
407407+408408+ #[test]
409409+ fn test_collect_refs_at_embed() {
410410+ let markdown = "![[at://did:plc:xyz/app.bsky.feed.post/abc]]";
411411+ let refs = collect_refs_from_markdown(markdown);
412412+413413+ assert_eq!(refs.len(), 1);
414414+ assert!(matches!(
415415+ &refs[0],
416416+ ExtractedRef::AtEmbed { uri, .. } if uri == "at://did:plc:xyz/app.bsky.feed.post/abc"
417417+ ));
418418+ }
419419+420420+ #[test]
421421+ fn test_resolved_content_wikilink_lookup() {
422422+ let mut content = ResolvedContent::new();
423423+ content.add_entry("My Note", "/alice/notebook/my_note", "My Note");
424424+425425+ let result = content.resolve_wikilink("my note");
426426+ assert!(result.is_some());
427427+ assert_eq!(
428428+ result.unwrap().canonical_path.as_ref(),
429429+ "/alice/notebook/my_note"
430430+ );
431431+ }
432432+433433+ #[test]
434434+ fn test_resolved_content_embed_lookup() {
435435+ let mut content = ResolvedContent::new();
436436+ let uri = AtUri::new("at://did:plc:xyz/app.bsky.feed.post/abc").unwrap();
437437+ content.add_embed(uri.into_static(), "<div>post content</div>", None);
438438+439439+ let lookup_uri = AtUri::new("at://did:plc:xyz/app.bsky.feed.post/abc").unwrap();
440440+ let result = content.get_embed_content(&lookup_uri);
441441+ assert!(result.is_some());
442442+ assert_eq!(result.unwrap(), "<div>post content</div>");
443443+ }
444444+}
+1
crates/weaver-renderer/Cargo.toml
···2525pin-utils = "0.1.0"
2626pin-project = "1.1.10"
2727smol_str = { version = "0.3", features = ["serde"] }
2828+pulldown-latex = "0.6"
2829mime-sniffer = "0.1.3"
2930reqwest = { version = "0.12.7", default-features = false, features = [
3031 "json",
+1-1
crates/weaver-renderer/src/atproto.rs
···14141515pub use client::{ClientContext, DefaultEmbedResolver, EmbedResolver};
1616pub use embed_renderer::{
1717- fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile,
1717+ fetch_and_render, fetch_and_render_generic, fetch_and_render_post, fetch_and_render_profile,
1818};
1919pub use error::{AtProtoPreprocessError, ClientRenderError};
2020pub use markdown_writer::MarkdownWriter;
+196-100
crates/weaver-renderer/src/atproto/client.rs
···55 prelude::IdentityResolver,
66 types::string::{AtUri, Cid, Did},
77};
88-use markdown_weaver::{CowStr as MdCowStr, Tag, WeaverAttributes};
88+use markdown_weaver::{CowStr as MdCowStr, LinkType, Tag, WeaverAttributes};
99use std::collections::HashMap;
1010use std::sync::Arc;
1111use weaver_api::sh_weaver::notebook::entry::Entry;
1212+use weaver_common::{EntryIndex, ResolvedContent};
12131314/// Trait for resolving embed content on the client side
1415///
···5253impl<A: AgentSession + IdentityResolver> EmbedResolver for DefaultEmbedResolver<A> {
5354 async fn resolve_profile(&self, uri: &AtUri<'_>) -> Result<String, ClientRenderError> {
5455 use crate::atproto::fetch_and_render_profile;
5555- use jacquard::types::ident::AtIdentifier;
5656-5757- // Extract DID from authority
5858- let did = match uri.authority() {
5959- AtIdentifier::Did(did) => did,
6060- AtIdentifier::Handle(_) => {
6161- return Err(ClientRenderError::EntryFetch {
6262- uri: uri.as_ref().to_string(),
6363- source: "Profile URI should use DID not handle".into(),
6464- });
6565- }
6666- };
6767-6868- fetch_and_render_profile(&did, &*self.agent)
5656+ fetch_and_render_profile(uri.authority(), &*self.agent)
6957 .await
7058 .map_err(|e| ClientRenderError::EntryFetch {
7159 uri: uri.as_ref().to_string(),
···144132 embed_resolver: Option<Arc<R>>,
145133 embed_depth: usize,
146134135135+ // Pre-resolved content for sync rendering
136136+ entry_index: Option<EntryIndex>,
137137+ resolved_content: Option<ResolvedContent>,
138138+147139 // Shared state
148140 frontmatter: Frontmatter,
149141 title: MdCowStr<'a>,
···160152 blob_map,
161153 embed_resolver: None,
162154 embed_depth: 0,
155155+ entry_index: None,
156156+ resolved_content: None,
163157 frontmatter: Frontmatter::default(),
164158 title,
165159 }
···173167 blob_map: self.blob_map,
174168 embed_resolver: Some(resolver),
175169 embed_depth: self.embed_depth,
170170+ entry_index: self.entry_index,
171171+ resolved_content: self.resolved_content,
176172 frontmatter: self.frontmatter,
177173 title: self.title,
178174 }
179175 }
176176+177177+ /// Add an entry index for wikilink resolution
178178+ pub fn with_entry_index(mut self, index: EntryIndex) -> Self {
179179+ self.entry_index = Some(index);
180180+ self
181181+ }
182182+183183+ /// Add pre-resolved content for sync rendering
184184+ pub fn with_resolved_content(mut self, content: ResolvedContent) -> Self {
185185+ self.resolved_content = Some(content);
186186+ self
187187+ }
180188}
181189182190impl<'a, R: EmbedResolver> ClientContext<'a, R> {
···191199 blob_map: self.blob_map.clone(),
192200 embed_resolver: self.embed_resolver.clone(),
193201 embed_depth: depth,
202202+ entry_index: self.entry_index.clone(),
203203+ resolved_content: self.resolved_content.clone(),
194204 frontmatter: self.frontmatter.clone(),
195205 title: self.title.clone(),
196206 }
197207 }
198208209209+ /// Build an embed tag with resolved content attached
210210+ fn build_embed_with_content<'s>(
211211+ &self,
212212+ embed_type: markdown_weaver::EmbedType,
213213+ url: String,
214214+ title: MdCowStr<'s>,
215215+ id: MdCowStr<'s>,
216216+ content: String,
217217+ is_at_uri: bool,
218218+ ) -> Tag<'s> {
219219+ let mut attrs = WeaverAttributes {
220220+ classes: vec![],
221221+ attrs: vec![],
222222+ };
223223+224224+ attrs.attrs.push(("content".into(), content.into()));
225225+226226+ // Add metadata for client-side enhancement
227227+ if is_at_uri {
228228+ attrs
229229+ .attrs
230230+ .push(("data-embed-uri".into(), url.clone().into()));
231231+232232+ if let Ok(at_uri) = AtUri::new(&url) {
233233+ if at_uri.collection().is_none() {
234234+ attrs
235235+ .attrs
236236+ .push(("data-embed-type".into(), "profile".into()));
237237+ } else {
238238+ attrs.attrs.push(("data-embed-type".into(), "post".into()));
239239+ }
240240+ }
241241+ }
242242+243243+ Tag::Embed {
244244+ embed_type,
245245+ dest_url: MdCowStr::Boxed(url.into_boxed_str()),
246246+ title,
247247+ id,
248248+ attrs: Some(attrs),
249249+ }
250250+ }
251251+199252 fn build_blob_map<'b>(entry: &Entry<'b>) -> HashMap<BlobName<'static>, Cid<'static>> {
200253 use jacquard::IntoStatic;
201254···295348 title,
296349 id,
297350 } => {
351351+ // Handle WikiLinks via EntryIndex
352352+ if matches!(link_type, LinkType::WikiLink { .. }) {
353353+ if let Some(index) = &self.entry_index {
354354+ let url = dest_url.as_ref();
355355+ if let Some((path, _title, fragment)) = index.resolve(url) {
356356+ // Build resolved URL with optional fragment
357357+ let resolved_url = match fragment {
358358+ Some(frag) => format!("{}#{}", path, frag),
359359+ None => path.to_string(),
360360+ };
361361+362362+ return Tag::Link {
363363+ link_type: *link_type,
364364+ dest_url: MdCowStr::Boxed(resolved_url.into_boxed_str()),
365365+ title: title.clone(),
366366+ id: id.clone(),
367367+ };
368368+ }
369369+ }
370370+ // Unresolved wikilink - render as broken link
371371+ return Tag::Link {
372372+ link_type: *link_type,
373373+ dest_url: MdCowStr::Boxed(format!("#{}", dest_url).into_boxed_str()),
374374+ title: title.clone(),
375375+ id: id.clone(),
376376+ };
377377+ }
378378+298379 let url = dest_url.as_ref();
299380300381 // Try to parse as AT URI
···324405 }
325406326407 async fn handle_embed<'s>(&self, embed: Tag<'s>) -> Tag<'s> {
327327- match &embed {
328328- Tag::Embed {
329329- embed_type,
330330- dest_url,
331331- title,
332332- id,
333333- attrs,
334334- } => {
335335- // If content already in attrs (from preprocessor), pass through
336336- if let Some(attrs) = attrs {
337337- if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") {
338338- return embed;
339339- }
340340- }
341341-342342- // Check if we have a resolver
343343- let Some(resolver) = &self.embed_resolver else {
344344- return embed;
345345- };
346346-347347- // Check recursion depth
348348- if self.embed_depth >= MAX_EMBED_DEPTH {
349349- return embed;
350350- }
351351-352352- // Try to fetch content based on URL type
353353- let content_result = if dest_url.starts_with("at://") {
354354- // AT Protocol embed
355355- if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) {
356356- if at_uri.collection().is_none() && at_uri.rkey().is_none() {
357357- // Profile embed
358358- resolver.resolve_profile(&at_uri).await
359359- } else {
360360- // Post/record embed
361361- resolver.resolve_post(&at_uri).await
362362- }
363363- } else {
364364- return embed;
365365- }
366366- } else if dest_url.starts_with("http://") || dest_url.starts_with("https://") {
367367- // Markdown embed (could be other types, but assume markdown for now)
368368- resolver
369369- .resolve_markdown(dest_url.as_ref(), self.embed_depth + 1)
370370- .await
371371- } else {
372372- // Local path or other - skip for now
373373- return embed;
374374- };
408408+ let Tag::Embed {
409409+ embed_type,
410410+ dest_url,
411411+ title,
412412+ id,
413413+ attrs,
414414+ } = &embed
415415+ else {
416416+ return embed;
417417+ };
375418376376- // If we got content, attach it to attrs
377377- if let Ok(content) = content_result {
378378- let mut new_attrs = attrs.clone().unwrap_or_else(|| WeaverAttributes {
379379- classes: vec![],
380380- attrs: vec![],
381381- });
419419+ // If content already in attrs (from preprocessor), pass through
420420+ if let Some(attrs) = attrs {
421421+ if attrs.attrs.iter().any(|(k, _)| k.as_ref() == "content") {
422422+ return embed;
423423+ }
424424+ }
382425383383- new_attrs.attrs.push(("content".into(), content.into()));
426426+ // Own the URL to avoid borrow issues
427427+ let url: String = dest_url.to_string();
384428385385- // Add metadata for client-side enhancement
386386- if dest_url.starts_with("at://") {
387387- new_attrs
388388- .attrs
389389- .push(("data-embed-uri".into(), dest_url.clone()));
429429+ // Check recursion depth
430430+ if self.embed_depth >= MAX_EMBED_DEPTH {
431431+ return embed;
432432+ }
390433391391- if let Ok(at_uri) = AtUri::new(dest_url.as_ref()) {
392392- if at_uri.collection().is_none() {
393393- new_attrs
394394- .attrs
395395- .push(("data-embed-type".into(), "profile".into()));
396396- } else {
397397- new_attrs
398398- .attrs
399399- .push(("data-embed-type".into(), "post".into()));
400400- }
401401- }
402402- } else {
403403- new_attrs
404404- .attrs
405405- .push(("data-embed-type".into(), "markdown".into()));
434434+ // First check for pre-resolved AT URI content
435435+ if url.starts_with("at://") {
436436+ if let Ok(at_uri) = AtUri::new(&url) {
437437+ if let Some(resolved) = &self.resolved_content {
438438+ if let Some(content) = resolved.get_embed_content(&at_uri) {
439439+ return self.build_embed_with_content(
440440+ *embed_type,
441441+ url.clone(),
442442+ title.clone(),
443443+ id.clone(),
444444+ content.to_string(),
445445+ true,
446446+ );
406447 }
448448+ }
449449+ }
450450+ }
407451408408- Tag::Embed {
452452+ // Check for wikilink-style embed (![[Entry Name]]) via entry index
453453+ if !url.starts_with("at://") && !url.starts_with("http://") && !url.starts_with("https://")
454454+ {
455455+ if let Some(index) = &self.entry_index {
456456+ if let Some((path, _title, fragment)) = index.resolve(&url) {
457457+ // Entry embed - link to the entry
458458+ let resolved_url = match fragment {
459459+ Some(frag) => format!("{}#{}", path, frag),
460460+ None => path.to_string(),
461461+ };
462462+ return Tag::Embed {
409463 embed_type: *embed_type,
410410- dest_url: dest_url.clone(),
464464+ dest_url: MdCowStr::Boxed(resolved_url.into_boxed_str()),
411465 title: title.clone(),
412466 id: id.clone(),
413413- attrs: Some(new_attrs),
414414- }
467467+ attrs: attrs.clone(),
468468+ };
469469+ }
470470+ }
471471+ // Unresolved entry embed - pass through
472472+ return embed;
473473+ }
474474+475475+ // Fallback to async resolver if available
476476+ let Some(resolver) = &self.embed_resolver else {
477477+ return embed;
478478+ };
479479+480480+ // Try to fetch content based on URL type
481481+ let content_result = if url.starts_with("at://") {
482482+ // AT Protocol embed
483483+ if let Ok(at_uri) = AtUri::new(&url) {
484484+ if at_uri.collection().is_none() && at_uri.rkey().is_none() {
485485+ // Profile embed
486486+ resolver.resolve_profile(&at_uri).await
415487 } else {
416416- // Fetch failed, return original
417417- embed
488488+ // Post/record embed
489489+ resolver.resolve_post(&at_uri).await
418490 }
491491+ } else {
492492+ return embed;
419493 }
420420- _ => embed,
494494+ } else if url.starts_with("http://") || url.starts_with("https://") {
495495+ // Markdown embed
496496+ resolver.resolve_markdown(&url, self.embed_depth + 1).await
497497+ } else {
498498+ return embed;
499499+ };
500500+501501+ // If we got content, attach it
502502+ if let Ok(content) = content_result {
503503+ let is_at = url.starts_with("at://");
504504+ self.build_embed_with_content(
505505+ *embed_type,
506506+ url,
507507+ title.clone(),
508508+ id.clone(),
509509+ content,
510510+ is_at,
511511+ )
512512+ } else {
513513+ embed
421514 }
422515 }
423516···452545 #[test]
453546 fn test_at_uri_to_web_url_profile() {
454547 let uri = AtUri::new("at://did:plc:xyz123").unwrap();
455455- assert_eq!(at_uri_to_web_url(&uri), "https://weaver.sh/did:plc:xyz123");
548548+ assert_eq!(
549549+ at_uri_to_web_url(&uri),
550550+ "https://alpha.weaver.sh/did:plc:xyz123"
551551+ );
456552 }
457553458554 #[test]
···496592 let uri = AtUri::new("at://did:plc:xyz123/sh.weaver.notebook.entry/entry123").unwrap();
497593 assert_eq!(
498594 at_uri_to_web_url(&uri),
499499- "https://weaver.sh/did:plc:xyz123/sh.weaver.notebook.entry/entry123"
595595+ "https://alpha.weaver.sh/record/at://did:plc:xyz123/sh.weaver.notebook.entry/entry123"
500596 );
501597 }
502598···505601 let uri = AtUri::new("at://did:plc:xyz123/com.example.unknown/rkey").unwrap();
506602 assert_eq!(
507603 at_uri_to_web_url(&uri),
508508- "https://weaver.sh/did:plc:xyz123/com.example.unknown/rkey"
604604+ "https://alpha.weaver.sh/record/at://did:plc:xyz123/com.example.unknown/rkey"
509605 );
510606 }
511607}
···2727pub mod base_html;
2828pub mod code_pretty;
2929pub mod css;
3030+pub mod math;
3031#[cfg(not(target_family = "wasm"))]
3132pub mod static_site;
3233pub mod theme;
+117
crates/weaver-renderer/src/math.rs
···11+//! LaTeX math rendering via pulldown-latex → MathML
22+33+use markdown_weaver_escape::escape_html;
44+use pulldown_latex::{
55+ config::DisplayMode, config::RenderConfig, mathml::push_mathml, Parser, Storage,
66+};
77+88+/// Result of attempting to render LaTeX math
99+pub enum MathResult {
1010+ /// Successfully rendered MathML
1111+ Success(String),
1212+ /// Rendering failed - contains fallback HTML with source and error message
1313+ Error { html: String, message: String },
1414+}
1515+1616+/// Render LaTeX math to MathML
1717+///
1818+/// # Arguments
1919+/// * `latex` - The LaTeX source string (without delimiters like $ or $$)
2020+/// * `display_mode` - If true, render as display math (block); if false, inline
2121+pub fn render_math(latex: &str, display_mode: bool) -> MathResult {
2222+ let storage = Storage::new();
2323+ let parser = Parser::new(latex, &storage);
2424+ let config = RenderConfig {
2525+ display_mode: if display_mode {
2626+ DisplayMode::Block
2727+ } else {
2828+ DisplayMode::Inline
2929+ },
3030+ ..Default::default()
3131+ };
3232+3333+ let mut mathml = String::new();
3434+3535+ // Collect events, tracking any errors
3636+ let events: Vec<_> = parser.collect();
3737+ let errors: Vec<String> = events
3838+ .iter()
3939+ .filter_map(|e| e.as_ref().err().map(|err| err.to_string()))
4040+ .collect();
4141+4242+ if errors.is_empty() {
4343+ // All events parsed successfully - push_mathml wants the Results directly
4444+ if let Err(e) = push_mathml(&mut mathml, events.into_iter(), config) {
4545+ return MathResult::Error {
4646+ html: format_error_html(latex, &e.to_string(), display_mode),
4747+ message: e.to_string(),
4848+ };
4949+ }
5050+ MathResult::Success(mathml)
5151+ } else {
5252+ // Had parse errors - return error HTML
5353+ let error_msg = errors.join("; ");
5454+ MathResult::Error {
5555+ html: format_error_html(latex, &error_msg, display_mode),
5656+ message: error_msg,
5757+ }
5858+ }
5959+}
6060+6161+fn format_error_html(latex: &str, error: &str, display_mode: bool) -> String {
6262+ let mode_class = if display_mode {
6363+ "math-display"
6464+ } else {
6565+ "math-inline"
6666+ };
6767+ let mut escaped_latex = String::new();
6868+ let mut escaped_error = String::new();
6969+ // These won't fail writing to String
7070+ let _ = escape_html(&mut escaped_latex, latex);
7171+ let _ = escape_html(&mut escaped_error, error);
7272+ format!(
7373+ r#"<span class="math math-error {mode_class}" title="{escaped_error}"><code>{escaped_latex}</code></span>"#
7474+ )
7575+}
7676+7777+#[cfg(test)]
7878+mod tests {
7979+ use super::*;
8080+8181+ #[test]
8282+ fn renders_inline_math() {
8383+ let result = render_math("x^2", false);
8484+ assert!(matches!(result, MathResult::Success(_)));
8585+ if let MathResult::Success(mathml) = result {
8686+ assert!(mathml.contains("<math"));
8787+ assert!(mathml.contains("</math>"));
8888+ }
8989+ }
9090+9191+ #[test]
9292+ fn renders_display_math() {
9393+ let result = render_math(r"\frac{a}{b}", true);
9494+ assert!(matches!(result, MathResult::Success(_)));
9595+ if let MathResult::Success(mathml) = result {
9696+ assert!(mathml.contains("<math"));
9797+ assert!(mathml.contains("<mfrac"));
9898+ }
9999+ }
100100+101101+ #[test]
102102+ fn renders_complex_math() {
103103+ let result = render_math(r"\sum_{i=0}^{n} x_i", true);
104104+ assert!(matches!(result, MathResult::Success(_)));
105105+ }
106106+107107+ #[test]
108108+ fn handles_invalid_latex() {
109109+ // Unclosed brace
110110+ let result = render_math(r"\frac{a", false);
111111+ assert!(matches!(result, MathResult::Error { .. }));
112112+ if let MathResult::Error { html, message } = result {
113113+ assert!(html.contains("math-error"));
114114+ assert!(!message.is_empty());
115115+ }
116116+ }
117117+}