diff --git a/Cargo.lock b/Cargo.lock index 26b7698..fa644f4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12125,10 +12125,17 @@ dependencies = [ name = "weaver-editor-core" version = "0.1.0" dependencies = [ + "jacquard", + "markdown-weaver", + "markdown-weaver-escape", "ropey", "smol_str", + "syntect", "thiserror 2.0.17", "tracing", + "weaver-api", + "weaver-common", + "weaver-renderer", "web-time", ] diff --git a/crates/weaver-editor-core/Cargo.toml b/crates/weaver-editor-core/Cargo.toml index 06d6b18..d3d4467 100644 --- a/crates/weaver-editor-core/Cargo.toml +++ b/crates/weaver-editor-core/Cargo.toml @@ -10,5 +10,12 @@ smol_str = "0.3" thiserror = { workspace = true } tracing = { workspace = true } web-time = "1.1" +markdown-weaver = { workspace = true } +markdown-weaver-escape = { workspace = true } +weaver-common = { path = "../weaver-common" } +weaver-renderer = { path = "../weaver-renderer" } +weaver-api = { path = "../weaver-api" } +jacquard = { workspace = true } +syntect = { workspace = true } [dev-dependencies] diff --git a/crates/weaver-editor-core/src/document.rs b/crates/weaver-editor-core/src/document.rs new file mode 100644 index 0000000..ee79d32 --- /dev/null +++ b/crates/weaver-editor-core/src/document.rs @@ -0,0 +1,461 @@ +//! Core editor document trait and implementations. +//! +//! Defines the `EditorDocument` trait for abstracting editor behavior, +//! allowing different storage strategies (plain fields vs Signals) while +//! sharing the core editing logic. + +use std::ops::Range; + +use smol_str::SmolStr; +use web_time::Instant; + +use crate::text::TextBuffer; +use crate::types::{BLOCK_SYNTAX_ZONE, CompositionState, CursorState, EditInfo, Selection}; +use crate::undo::UndoManager; + +/// Core trait for editor documents. +/// +/// Defines the interface for any editor implementation. Different backends +/// can implement this trait with different storage strategies: +/// - `PlainEditor`: Simple field-based storage +/// - Reactive implementations: Use Signals/state management +/// +/// The trait is generic over the buffer type, which must implement both +/// `TextBuffer` (for text operations) and `UndoManager` (for undo/redo). +pub trait EditorDocument { + /// The buffer type used for text storage and undo. + type Buffer: TextBuffer + UndoManager; + + // === Required: Buffer access === + + /// Get a reference to the underlying buffer. + fn buffer(&self) -> &Self::Buffer; + + /// Get a mutable reference to the underlying buffer. + fn buffer_mut(&mut self) -> &mut Self::Buffer; + + // === Required: Cursor/selection state === + + /// Get the current cursor state. + fn cursor(&self) -> CursorState; + + /// Set the cursor state. + fn set_cursor(&mut self, cursor: CursorState); + + /// Get the current selection, if any. + fn selection(&self) -> Option; + + /// Set the selection. + fn set_selection(&mut self, selection: Option); + + // === Required: Edit tracking === + + /// Get the last edit info, if any. + fn last_edit(&self) -> Option; + + /// Set the last edit info. + fn set_last_edit(&mut self, edit: Option); + + // === Required: Composition (IME) state === + + /// Get the current composition state. + fn composition(&self) -> Option; + + /// Set the composition state. + fn set_composition(&mut self, composition: Option); + + // === Provided: Convenience accessors === + + /// Get the cursor offset. + fn cursor_offset(&self) -> usize { + self.cursor().offset + } + + /// Set just the cursor offset, preserving other cursor state. + fn set_cursor_offset(&mut self, offset: usize) { + let mut cursor = self.cursor(); + cursor.offset = offset; + self.set_cursor(cursor); + } + + /// Get the full content as a String. + fn content_string(&self) -> String { + self.buffer().to_string() + } + + /// Get length in characters. + fn len_chars(&self) -> usize { + self.buffer().len_chars() + } + + /// Get length in bytes. + fn len_bytes(&self) -> usize { + self.buffer().len_bytes() + } + + /// Check if document is empty. + fn is_empty(&self) -> bool { + self.buffer().len_chars() == 0 + } + + /// Get a slice of the content. + fn slice(&self, range: Range) -> Option { + self.buffer().slice(range) + } + + /// Get character at offset. + fn char_at(&self, offset: usize) -> Option { + self.buffer().char_at(offset) + } + + /// Convert char offset to byte offset. + fn char_to_byte(&self, char_offset: usize) -> usize { + self.buffer().char_to_byte(char_offset) + } + + /// Convert byte offset to char offset. + fn byte_to_char(&self, byte_offset: usize) -> usize { + self.buffer().byte_to_char(byte_offset) + } + + /// Get selected text, if any. + fn selected_text(&self) -> Option { + self.selection() + .and_then(|sel| self.buffer().slice(sel.to_range())) + } + + // === Provided: Text operations === + + /// Insert text at char offset, returning edit info. + fn insert(&mut self, offset: usize, text: &str) -> EditInfo { + let contains_newline = text.contains('\n'); + let in_block_syntax_zone = self.is_in_block_syntax_zone(offset); + + self.buffer_mut().insert(offset, text); + + let inserted_len = text.chars().count(); + self.set_cursor_offset(offset + inserted_len); + + let edit = EditInfo { + edit_char_pos: offset, + inserted_len, + deleted_len: 0, + contains_newline, + in_block_syntax_zone, + doc_len_after: self.buffer().len_chars(), + timestamp: Instant::now(), + }; + + self.set_last_edit(Some(edit.clone())); + edit + } + + /// Delete char range, returning edit info. + fn delete(&mut self, range: Range) -> EditInfo { + let deleted_text = self.buffer().slice(range.clone()); + let contains_newline = deleted_text + .as_ref() + .map(|s| s.contains('\n')) + .unwrap_or(false); + let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start); + let deleted_len = range.end - range.start; + + self.buffer_mut().delete(range.clone()); + self.set_cursor_offset(range.start); + + let edit = EditInfo { + edit_char_pos: range.start, + inserted_len: 0, + deleted_len, + contains_newline, + in_block_syntax_zone, + doc_len_after: self.buffer().len_chars(), + timestamp: Instant::now(), + }; + + self.set_last_edit(Some(edit.clone())); + edit + } + + /// Replace char range with text, returning edit info. + fn replace(&mut self, range: Range, text: &str) -> EditInfo { + let deleted_text = self.buffer().slice(range.clone()); + let deleted_contains_newline = deleted_text + .as_ref() + .map(|s| s.contains('\n')) + .unwrap_or(false); + let contains_newline = text.contains('\n') || deleted_contains_newline; + let in_block_syntax_zone = self.is_in_block_syntax_zone(range.start); + let deleted_len = range.end - range.start; + + self.buffer_mut().delete(range.clone()); + self.buffer_mut().insert(range.start, text); + + let inserted_len = text.chars().count(); + self.set_cursor_offset(range.start + inserted_len); + + let edit = EditInfo { + edit_char_pos: range.start, + inserted_len, + deleted_len, + contains_newline, + in_block_syntax_zone, + doc_len_after: self.buffer().len_chars(), + timestamp: Instant::now(), + }; + + self.set_last_edit(Some(edit.clone())); + edit + } + + /// Delete the current selection, if any. + fn delete_selection(&mut self) -> Option { + let sel = self.selection()?; + self.set_selection(None); + if sel.is_collapsed() { + return None; + } + Some(self.delete(sel.to_range())) + } + + // === Provided: Undo/Redo === + + fn undo(&mut self) -> bool { + self.buffer_mut().undo() + } + + fn redo(&mut self) -> bool { + self.buffer_mut().redo() + } + + fn can_undo(&self) -> bool { + self.buffer().can_undo() + } + + fn can_redo(&self) -> bool { + self.buffer().can_redo() + } + + fn clear_history(&mut self) { + self.buffer_mut().clear_history(); + } + + // === Provided: Helpers === + + /// Check if offset is in the block-syntax zone (first ~6 chars of line). + fn is_in_block_syntax_zone(&self, offset: usize) -> bool { + let mut line_start = offset; + while line_start > 0 { + if let Some('\n') = self.buffer().char_at(line_start - 1) { + break; + } + line_start -= 1; + } + offset - line_start < BLOCK_SYNTAX_ZONE + } +} + +/// Simple field-based implementation of EditorDocument. +/// +/// Stores cursor, selection, and edit state as plain fields. +/// Use this for non-reactive contexts or as a base for testing. +#[derive(Clone)] +pub struct PlainEditor { + buffer: T, + cursor: CursorState, + selection: Option, + last_edit: Option, + composition: Option, +} + +impl Default for PlainEditor { + fn default() -> Self { + Self::new(T::default()) + } +} + +impl PlainEditor { + /// Create a new editor with the given buffer. + pub fn new(buffer: T) -> Self { + Self { + buffer, + cursor: CursorState::default(), + selection: None, + last_edit: None, + composition: None, + } + } + + /// Get direct access to the inner buffer (bypasses trait). + pub fn inner(&self) -> &T { + &self.buffer + } + + /// Get direct mutable access to the inner buffer (bypasses trait). + pub fn inner_mut(&mut self) -> &mut T { + &mut self.buffer + } +} + +impl EditorDocument for PlainEditor { + type Buffer = T; + + fn buffer(&self) -> &Self::Buffer { + &self.buffer + } + + fn buffer_mut(&mut self) -> &mut Self::Buffer { + &mut self.buffer + } + + fn cursor(&self) -> CursorState { + self.cursor.clone() + } + + fn set_cursor(&mut self, cursor: CursorState) { + self.cursor = cursor; + } + + fn selection(&self) -> Option { + self.selection.clone() + } + + fn set_selection(&mut self, selection: Option) { + self.selection = selection; + } + + fn last_edit(&self) -> Option { + self.last_edit.clone() + } + + fn set_last_edit(&mut self, edit: Option) { + self.last_edit = edit; + } + + fn composition(&self) -> Option { + self.composition.clone() + } + + fn set_composition(&mut self, composition: Option) { + self.composition = composition; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{EditorRope, UndoableBuffer}; + + type TestEditor = PlainEditor>; + + fn make_editor(content: &str) -> TestEditor { + let rope = EditorRope::from_str(content); + let buf = UndoableBuffer::new(rope, 100); + PlainEditor::new(buf) + } + + #[test] + fn test_basic_insert() { + let mut editor = make_editor("hello"); + assert_eq!(editor.content_string(), "hello"); + + let edit = editor.insert(5, " world"); + assert_eq!(editor.content_string(), "hello world"); + assert_eq!(edit.inserted_len, 6); + assert_eq!(editor.cursor_offset(), 11); + } + + #[test] + fn test_delete() { + let mut editor = make_editor("hello world"); + + let edit = editor.delete(5..11); + assert_eq!(editor.content_string(), "hello"); + assert_eq!(edit.deleted_len, 6); + assert_eq!(editor.cursor_offset(), 5); + } + + #[test] + fn test_replace() { + let mut editor = make_editor("hello world"); + + let edit = editor.replace(6..11, "rust"); + assert_eq!(editor.content_string(), "hello rust"); + assert_eq!(edit.deleted_len, 5); + assert_eq!(edit.inserted_len, 4); + } + + #[test] + fn test_undo_redo() { + let mut editor = make_editor("hello"); + + editor.insert(5, " world"); + assert_eq!(editor.content_string(), "hello world"); + + assert!(editor.undo()); + assert_eq!(editor.content_string(), "hello"); + + assert!(editor.redo()); + assert_eq!(editor.content_string(), "hello world"); + } + + #[test] + fn test_selection() { + let mut editor = make_editor("hello world"); + + editor.set_selection(Some(Selection::new(0, 5))); + assert_eq!(editor.selected_text(), Some("hello".into())); + + let edit = editor.delete_selection(); + assert!(edit.is_some()); + assert_eq!(editor.content_string(), " world"); + assert!(editor.selection().is_none()); + } + + #[test] + fn test_block_syntax_zone() { + let mut editor = make_editor("# heading\nparagraph"); + + // Position 0 is in block syntax zone + let edit = editor.insert(0, "x"); + assert!(edit.in_block_syntax_zone); + + // Position after newline (start of "paragraph") is also in zone + // Original was "# heading\nparagraph", after insert "x# heading\nparagraph" + // Position 11 is start of "paragraph" line + let edit = editor.insert(11, "y"); + assert!(edit.in_block_syntax_zone); + } + + #[test] + fn test_composition_state() { + let mut editor = make_editor("hello"); + + assert!(editor.composition().is_none()); + + let comp = CompositionState::new(5, "わ".into()); + editor.set_composition(Some(comp.clone())); + + assert_eq!(editor.composition(), Some(comp)); + + editor.set_composition(None); + assert!(editor.composition().is_none()); + } + + #[test] + fn test_offset_conversions() { + let editor = make_editor("héllo wörld"); // multi-byte chars + + // 'é' is 2 bytes, 'ö' is 2 bytes + // chars: h é l l o w ö r l d + // idx: 0 1 2 3 4 5 6 7 8 9 10 + + assert_eq!(editor.len_chars(), 11); + assert!(editor.len_bytes() > 11); // multi-byte chars + + // char 1 ('é') starts at byte 1 + assert_eq!(editor.char_to_byte(1), 1); + // char 2 ('l') starts after 'é' (2 bytes) + assert_eq!(editor.char_to_byte(2), 3); + } +} diff --git a/crates/weaver-editor-core/src/lib.rs b/crates/weaver-editor-core/src/lib.rs index 5def3d9..5cfb34b 100644 --- a/crates/weaver-editor-core/src/lib.rs +++ b/crates/weaver-editor-core/src/lib.rs @@ -3,15 +3,21 @@ //! This crate provides: //! - `TextBuffer` trait for text storage abstraction //! - `EditorRope` - ropey-backed implementation -//! - `EditorDocument` - generic document with undo support -//! - Rendering, actions, formatting - all generic over TextBuffer +//! - `UndoableBuffer` - TextBuffer wrapper with undo/redo +//! - `EditorDocument` trait - interface for editor implementations +//! - `PlainEditor` - simple field-based EditorDocument impl +//! - Rendering types and offset mapping utilities +pub mod document; pub mod offset_map; pub mod paragraph; +pub mod render; pub mod syntax; pub mod text; pub mod types; +pub mod undo; pub mod visibility; +pub mod writer; pub use offset_map::{ OffsetMapping, RenderResult, SnapDirection, SnappedPosition, find_mapping_for_byte, @@ -22,6 +28,10 @@ pub use smol_str::SmolStr; pub use syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; pub use text::{EditorRope, TextBuffer}; pub use types::{ - Affinity, CompositionState, CursorState, EditInfo, Selection, BLOCK_SYNTAX_ZONE, + Affinity, CompositionState, CursorState, EditInfo, EditorImage, Selection, BLOCK_SYNTAX_ZONE, }; +pub use document::{EditorDocument, PlainEditor}; +pub use render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; +pub use undo::{UndoManager, UndoableBuffer}; pub use visibility::VisibilityState; +pub use writer::{EditorImageResolver, EditorWriter, SegmentedWriter, WriterResult}; diff --git a/crates/weaver-editor-core/src/offset_map.rs b/crates/weaver-editor-core/src/offset_map.rs index cce517a..8933c7a 100644 --- a/crates/weaver-editor-core/src/offset_map.rs +++ b/crates/weaver-editor-core/src/offset_map.rs @@ -4,6 +4,7 @@ //! and content gets split across nodes (syntax highlighting). Offset maps //! track how source byte positions map to DOM node positions. +use smol_str::SmolStr; use std::ops::Range; /// Result of rendering markdown with offset tracking. @@ -46,7 +47,7 @@ pub struct OffsetMapping { /// DOM node ID containing this content /// For invisible content, this is the nearest visible container - pub node_id: String, + pub node_id: SmolStr, /// Position within the node /// - If child_index is Some: cursor at that child index in the element @@ -284,6 +285,7 @@ pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize #[cfg(test)] mod tests { use super::*; + use smol_str::ToSmolStr; #[test] fn test_find_mapping_by_byte() { @@ -291,7 +293,7 @@ mod tests { OffsetMapping { byte_range: 0..2, char_range: 0..2, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 0, child_index: None, utf16_len: 0, // invisible @@ -299,7 +301,7 @@ mod tests { OffsetMapping { byte_range: 2..5, char_range: 2..5, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 0, child_index: None, utf16_len: 3, @@ -307,7 +309,7 @@ mod tests { OffsetMapping { byte_range: 5..7, char_range: 5..7, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 3, child_index: None, utf16_len: 0, // invisible @@ -336,7 +338,7 @@ mod tests { OffsetMapping { byte_range: 0..2, char_range: 0..2, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 0, child_index: None, utf16_len: 0, // invisible @@ -344,7 +346,7 @@ mod tests { OffsetMapping { byte_range: 2..5, char_range: 2..5, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 0, child_index: None, utf16_len: 3, @@ -352,7 +354,7 @@ mod tests { OffsetMapping { byte_range: 5..7, char_range: 5..7, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 3, child_index: None, utf16_len: 0, // invisible @@ -380,7 +382,7 @@ mod tests { let mapping = OffsetMapping { byte_range: 10..20, char_range: 10..20, - node_id: "test".to_string(), + node_id: "test".to_smolstr(), char_offset_in_node: 0, child_index: None, utf16_len: 5, @@ -398,7 +400,7 @@ mod tests { let mapping = OffsetMapping { byte_range: 10..20, char_range: 8..15, // emoji example: fewer chars than bytes - node_id: "test".to_string(), + node_id: "test".to_smolstr(), char_offset_in_node: 0, child_index: None, utf16_len: 5, @@ -416,7 +418,7 @@ mod tests { OffsetMapping { byte_range: 0..2, char_range: 0..2, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 0, child_index: None, utf16_len: 0, // invisible: "![" @@ -424,7 +426,7 @@ mod tests { OffsetMapping { byte_range: 2..5, char_range: 2..5, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 0, child_index: None, utf16_len: 3, // visible: "alt" @@ -432,7 +434,7 @@ mod tests { OffsetMapping { byte_range: 5..15, char_range: 5..15, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 3, child_index: None, utf16_len: 0, // invisible: "](url.png)" @@ -440,7 +442,7 @@ mod tests { OffsetMapping { byte_range: 15..20, char_range: 15..20, - node_id: "n0".to_string(), + node_id: "n0".to_smolstr(), char_offset_in_node: 3, child_index: None, utf16_len: 5, // visible: " text" diff --git a/crates/weaver-editor-core/src/render.rs b/crates/weaver-editor-core/src/render.rs new file mode 100644 index 0000000..48d3659 --- /dev/null +++ b/crates/weaver-editor-core/src/render.rs @@ -0,0 +1,192 @@ +//! Rendering traits for the editor. +//! +//! These traits abstract over external concerns during rendering: +//! - Resolving embed URLs to HTML content +//! - Resolving image URLs to CDN paths +//! - Validating wikilinks +//! +//! Implementations are provided by the consuming application (e.g., weaver-app). + +/// Provides HTML content for embedded resources. +/// +/// When rendering markdown with embeds (e.g., `![[at://...]]`), this trait +/// is consulted to get the pre-rendered HTML for the embed. +pub trait EmbedContentProvider { + /// Get HTML content for an embed URL. + /// + /// Returns `Some(html)` if the embed content is available, + /// `None` to render a placeholder. + fn get_embed_html(&self, url: &str) -> Option<&str>; +} + +/// Unit type implementation - no embeds available. +impl EmbedContentProvider for () { + fn get_embed_html(&self, _url: &str) -> Option<&str> { + None + } +} + +/// Resolves image URLs from markdown to actual paths. +/// +/// Markdown may reference images by name (e.g., `/image/photo.jpg`). +/// This trait maps those to actual CDN URLs or data URLs. +pub trait ImageResolver { + /// Resolve an image URL from markdown to an actual URL. + /// + /// Returns `Some(resolved_url)` if the image is found, + /// `None` to use the original URL unchanged. + fn resolve_image_url(&self, url: &str) -> Option; +} + +/// Unit type implementation - no image resolution. +impl ImageResolver for () { + fn resolve_image_url(&self, _url: &str) -> Option { + None + } +} + +/// Validates wikilinks during rendering. +/// +/// Used to add CSS classes indicating whether a wikilink target exists. +pub trait WikilinkValidator { + /// Check if a wikilink target is valid (exists). + fn is_valid_link(&self, target: &str) -> bool; +} + +/// Unit type implementation - all links are valid. +impl WikilinkValidator for () { + fn is_valid_link(&self, _target: &str) -> bool { + true + } +} + +/// Reference implementations for common patterns. + +impl EmbedContentProvider for &T { + fn get_embed_html(&self, url: &str) -> Option<&str> { + (*self).get_embed_html(url) + } +} + +impl ImageResolver for &T { + fn resolve_image_url(&self, url: &str) -> Option { + (*self).resolve_image_url(url) + } +} + +impl WikilinkValidator for &T { + fn is_valid_link(&self, target: &str) -> bool { + (*self).is_valid_link(target) + } +} + +impl EmbedContentProvider for Option { + fn get_embed_html(&self, url: &str) -> Option<&str> { + self.as_ref().and_then(|p| p.get_embed_html(url)) + } +} + +impl ImageResolver for Option { + fn resolve_image_url(&self, url: &str) -> Option { + self.as_ref().and_then(|r| r.resolve_image_url(url)) + } +} + +impl WikilinkValidator for Option { + fn is_valid_link(&self, target: &str) -> bool { + self.as_ref().map(|v| v.is_valid_link(target)).unwrap_or(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestEmbedProvider; + + impl EmbedContentProvider for TestEmbedProvider { + fn get_embed_html(&self, url: &str) -> Option<&str> { + if url == "at://test/embed" { + Some("
Test Embed
") + } else { + None + } + } + } + + struct TestImageResolver; + + impl ImageResolver for TestImageResolver { + fn resolve_image_url(&self, url: &str) -> Option { + if url.starts_with("/image/") { + Some(format!("https://cdn.example.com{}", url)) + } else { + None + } + } + } + + struct TestWikilinkValidator { + valid: Vec, + } + + impl WikilinkValidator for TestWikilinkValidator { + fn is_valid_link(&self, target: &str) -> bool { + self.valid.iter().any(|v| v == target) + } + } + + #[test] + fn test_embed_provider() { + let provider = TestEmbedProvider; + assert_eq!( + provider.get_embed_html("at://test/embed"), + Some("
Test Embed
") + ); + assert_eq!(provider.get_embed_html("at://other"), None); + } + + #[test] + fn test_image_resolver() { + let resolver = TestImageResolver; + assert_eq!( + resolver.resolve_image_url("/image/photo.jpg"), + Some("https://cdn.example.com/image/photo.jpg".to_string()) + ); + assert_eq!(resolver.resolve_image_url("https://other.com/img.png"), None); + } + + #[test] + fn test_wikilink_validator() { + let validator = TestWikilinkValidator { + valid: vec!["Home".to_string(), "About".to_string()], + }; + assert!(validator.is_valid_link("Home")); + assert!(validator.is_valid_link("About")); + assert!(!validator.is_valid_link("Missing")); + } + + #[test] + fn test_unit_impls() { + let embed: () = (); + assert_eq!(embed.get_embed_html("anything"), None); + + let image: () = (); + assert_eq!(image.resolve_image_url("anything"), None); + + let wiki: () = (); + assert!(wiki.is_valid_link("anything")); // default true + } + + #[test] + fn test_option_impls() { + let some_provider: Option = Some(TestEmbedProvider); + assert_eq!( + some_provider.get_embed_html("at://test/embed"), + Some("
Test Embed
") + ); + + let none_provider: Option = None; + assert_eq!(none_provider.get_embed_html("at://test/embed"), None); + } +} diff --git a/crates/weaver-editor-core/src/types.rs b/crates/weaver-editor-core/src/types.rs index ce44eff..d641da6 100644 --- a/crates/weaver-editor-core/src/types.rs +++ b/crates/weaver-editor-core/src/types.rs @@ -3,8 +3,21 @@ //! These types are framework-agnostic and can be used with any text buffer implementation. use std::ops::Range; + +use jacquard::types::string::AtUri; +use weaver_api::sh_weaver::embed::images::Image; use web_time::Instant; +/// Image stored in the editor, with optional publish state tracking. +#[derive(Clone, Debug)] +pub struct EditorImage { + /// The lexicon Image type (deserialized via from_json_value) + pub image: Image<'static>, + /// AT-URI of the PublishedBlob record (for cleanup on publish/delete). + /// None for existing images that are already in an entry record. + pub published_blob_uri: Option>, +} + /// Cursor state including position and affinity. #[derive(Clone, Debug, Copy, PartialEq, Eq)] pub struct CursorState { diff --git a/crates/weaver-editor-core/src/undo.rs b/crates/weaver-editor-core/src/undo.rs new file mode 100644 index 0000000..ce617fb --- /dev/null +++ b/crates/weaver-editor-core/src/undo.rs @@ -0,0 +1,333 @@ +//! Undo/redo management for editor operations. +//! +//! Provides: +//! - `UndoManager` trait for abstracting undo implementations +//! - `UndoableBuffer` - wraps a TextBuffer and provides undo/redo + +use std::ops::Range; + +use smol_str::{SmolStr, ToSmolStr}; + +use crate::text::TextBuffer; + +/// Trait for managing undo/redo operations. +/// +/// Implementations must actually perform the undo/redo, not just track state. +/// For local editing, use `UndoableBuffer` which wraps a TextBuffer. +/// For Loro, wrap LoroText + loro::UndoManager together. +pub trait UndoManager { + /// Check if undo is available. + fn can_undo(&self) -> bool; + + /// Check if redo is available. + fn can_redo(&self) -> bool; + + /// Perform undo. Returns true if successful. + fn undo(&mut self) -> bool; + + /// Perform redo. Returns true if successful. + fn redo(&mut self) -> bool; + + /// Clear all undo/redo history. + fn clear_history(&mut self); +} + +/// A recorded edit operation for undo/redo. +#[derive(Debug, Clone)] +struct EditOperation { + /// Character position where edit occurred + pos: usize, + /// Text that was deleted (empty for pure insertions) + deleted: SmolStr, + /// Text that was inserted (empty for pure deletions) + inserted: SmolStr, +} + +/// A TextBuffer wrapper that tracks edits and provides undo/redo. +/// +/// This is the standard way to get undo support for local editing. +/// All mutations go through this wrapper, which records them for undo. +#[derive(Clone)] +pub struct UndoableBuffer { + buffer: T, + undo_stack: Vec, + redo_stack: Vec, + max_steps: usize, +} + +impl Default for UndoableBuffer { + fn default() -> Self { + Self::new(T::default(), 100) + } +} + +impl UndoableBuffer { + /// Create a new undoable buffer wrapping the given buffer. + pub fn new(buffer: T, max_steps: usize) -> Self { + Self { + buffer, + undo_stack: Vec::new(), + redo_stack: Vec::new(), + max_steps, + } + } + + /// Get a reference to the inner buffer. + pub fn inner(&self) -> &T { + &self.buffer + } + + /// Get a mutable reference to the inner buffer. + /// WARNING: Edits made directly bypass undo tracking! + pub fn inner_mut(&mut self) -> &mut T { + &mut self.buffer + } + + /// Record an operation (called internally by TextBuffer impl). + fn record_op(&mut self, pos: usize, deleted: &str, inserted: &str) { + // Clear redo stack on new edit + self.redo_stack.clear(); + + let op = EditOperation { + pos, + deleted: deleted.to_smolstr(), + inserted: inserted.to_smolstr(), + }; + + self.undo_stack.push(op); + + // Trim if over max + while self.undo_stack.len() > self.max_steps { + self.undo_stack.remove(0); + } + } +} + +// Implement TextBuffer by delegating to inner buffer + recording operations +impl TextBuffer for UndoableBuffer { + fn len_bytes(&self) -> usize { + self.buffer.len_bytes() + } + + fn len_chars(&self) -> usize { + self.buffer.len_chars() + } + + fn insert(&mut self, char_offset: usize, text: &str) { + self.record_op(char_offset, "", text); + self.buffer.insert(char_offset, text); + } + + fn delete(&mut self, char_range: Range) { + // Get the text being deleted for undo + let deleted = self + .buffer + .slice(char_range.clone()) + .map(|s| s.to_string()) + .unwrap_or_default(); + self.record_op(char_range.start, &deleted, ""); + self.buffer.delete(char_range); + } + + fn slice(&self, char_range: Range) -> Option { + self.buffer.slice(char_range) + } + + fn char_at(&self, char_offset: usize) -> Option { + self.buffer.char_at(char_offset) + } + + fn to_string(&self) -> String { + self.buffer.to_string() + } + + fn char_to_byte(&self, char_offset: usize) -> usize { + self.buffer.char_to_byte(char_offset) + } + + fn byte_to_char(&self, byte_offset: usize) -> usize { + self.buffer.byte_to_char(byte_offset) + } +} + +impl UndoManager for UndoableBuffer { + fn can_undo(&self) -> bool { + !self.undo_stack.is_empty() + } + + fn can_redo(&self) -> bool { + !self.redo_stack.is_empty() + } + + fn undo(&mut self) -> bool { + let Some(op) = self.undo_stack.pop() else { + return false; + }; + + // Apply inverse: delete what was inserted, insert what was deleted + let inserted_chars = op.inserted.chars().count(); + if inserted_chars > 0 { + self.buffer.delete(op.pos..op.pos + inserted_chars); + } + if !op.deleted.is_empty() { + self.buffer.insert(op.pos, &op.deleted); + } + + self.redo_stack.push(op); + true + } + + fn redo(&mut self) -> bool { + let Some(op) = self.redo_stack.pop() else { + return false; + }; + + // Re-apply original: delete what was deleted, insert what was inserted + let deleted_chars = op.deleted.chars().count(); + if deleted_chars > 0 { + self.buffer.delete(op.pos..op.pos + deleted_chars); + } + if !op.inserted.is_empty() { + self.buffer.insert(op.pos, &op.inserted); + } + + self.undo_stack.push(op); + true + } + + fn clear_history(&mut self) { + self.undo_stack.clear(); + self.redo_stack.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::EditorRope; + + #[test] + fn test_undoable_buffer_insert_undo() { + let rope = EditorRope::from_str("hello"); + let mut buf = UndoableBuffer::new(rope, 100); + + assert_eq!(buf.to_string(), "hello"); + assert!(!buf.can_undo()); + + // Insert " world" + buf.insert(5, " world"); + assert_eq!(buf.to_string(), "hello world"); + assert!(buf.can_undo()); + + // Undo + assert!(buf.undo()); + assert_eq!(buf.to_string(), "hello"); + assert!(!buf.can_undo()); + assert!(buf.can_redo()); + + // Redo + assert!(buf.redo()); + assert_eq!(buf.to_string(), "hello world"); + assert!(buf.can_undo()); + assert!(!buf.can_redo()); + } + + #[test] + fn test_undoable_buffer_delete_undo() { + let rope = EditorRope::from_str("hello world"); + let mut buf = UndoableBuffer::new(rope, 100); + + // Delete " world" + buf.delete(5..11); + assert_eq!(buf.to_string(), "hello"); + assert!(buf.can_undo()); + + // Undo + assert!(buf.undo()); + assert_eq!(buf.to_string(), "hello world"); + } + + #[test] + fn test_undoable_buffer_replace_undo() { + let rope = EditorRope::from_str("hello world"); + let mut buf = UndoableBuffer::new(rope, 100); + + // Replace "world" with "rust" + buf.delete(6..11); + buf.insert(6, "rust"); + assert_eq!(buf.to_string(), "hello rust"); + + // Undo insert + assert!(buf.undo()); + assert_eq!(buf.to_string(), "hello "); + + // Undo delete + assert!(buf.undo()); + assert_eq!(buf.to_string(), "hello world"); + } + + #[test] + fn test_new_edit_clears_redo() { + let rope = EditorRope::from_str("abc"); + let mut buf = UndoableBuffer::new(rope, 100); + + buf.insert(3, "d"); + assert!(buf.undo()); + assert!(buf.can_redo()); + + // New edit should clear redo + buf.insert(3, "e"); + assert!(!buf.can_redo()); + } + + #[test] + fn test_max_steps() { + let rope = EditorRope::from_str(""); + let mut buf = UndoableBuffer::new(rope, 3); + + buf.insert(0, "a"); + buf.insert(1, "b"); + buf.insert(2, "c"); + buf.insert(3, "d"); // should evict "a" + + assert_eq!(buf.to_string(), "abcd"); + + // Should only be able to undo 3 times + assert!(buf.undo()); // removes d + assert!(buf.undo()); // removes c + assert!(buf.undo()); // removes b + assert!(!buf.undo()); // a was evicted + + assert_eq!(buf.to_string(), "a"); + } + + #[test] + fn test_multiple_undo_redo_cycles() { + let rope = EditorRope::from_str(""); + let mut buf = UndoableBuffer::new(rope, 100); + + buf.insert(0, "a"); + buf.insert(1, "b"); + buf.insert(2, "c"); + assert_eq!(buf.to_string(), "abc"); + + // Undo all + assert!(buf.undo()); + assert!(buf.undo()); + assert!(buf.undo()); + assert_eq!(buf.to_string(), ""); + + // Redo all + assert!(buf.redo()); + assert!(buf.redo()); + assert!(buf.redo()); + assert_eq!(buf.to_string(), "abc"); + + // Partial undo then new edit + assert!(buf.undo()); // "ab" + assert!(buf.undo()); // "a" + buf.insert(1, "x"); + assert_eq!(buf.to_string(), "ax"); + assert!(!buf.can_redo()); // redo cleared + } +} diff --git a/crates/weaver-editor-core/src/writer/embed.rs b/crates/weaver-editor-core/src/writer/embed.rs new file mode 100644 index 0000000..9b4d32e --- /dev/null +++ b/crates/weaver-editor-core/src/writer/embed.rs @@ -0,0 +1,321 @@ +//! Embed rendering and image resolution for EditorWriter. + +use core::fmt; +use std::collections::HashMap; +use std::ops::Range; + +use jacquard::IntoStatic; +use jacquard::types::{ident::AtIdentifier, string::Rkey}; +use markdown_weaver::{CowStr, EmbedType, Event}; +use markdown_weaver_escape::{StrWrite, escape_html}; + +use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; +use crate::syntax::{SyntaxSpanInfo, SyntaxType}; +use crate::types::EditorImage; + +use super::EditorWriter; + +/// Resolved image path type. +#[derive(Clone, Debug)] +enum ResolvedImage { + /// Data URL for immediate preview (still uploading) + Pending(String), + /// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}` + Draft { + blob_rkey: Rkey<'static>, + ident: AtIdentifier<'static>, + }, + /// Published image: `/image/{ident}/{entry_rkey}/{name}` + Published { + entry_rkey: Rkey<'static>, + ident: AtIdentifier<'static>, + }, +} + +/// Resolves image paths in the editor. +/// +/// Supports three states for images: +/// - Pending: uses data URL for immediate preview while upload is in progress +/// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}` +/// - Published: uses path format `/image/{did}/{entry_rkey}/{name}` +/// +/// Image URLs in markdown use the format `/image/{name}`. +#[derive(Clone, Default)] +pub struct EditorImageResolver { + /// All resolved images: name -> resolved path info + images: HashMap, +} + +impl EditorImageResolver { + pub fn new() -> Self { + Self::default() + } + + /// Add a pending image with a data URL for immediate preview. + pub fn add_pending(&mut self, name: String, data_url: String) { + self.images.insert(name, ResolvedImage::Pending(data_url)); + } + + /// Promote a pending image to uploaded (draft) status. + pub fn promote_to_uploaded( + &mut self, + name: &str, + blob_rkey: Rkey<'static>, + ident: AtIdentifier<'static>, + ) { + self.images + .insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident }); + } + + /// Add an already-uploaded draft image. + pub fn add_uploaded( + &mut self, + name: String, + blob_rkey: Rkey<'static>, + ident: AtIdentifier<'static>, + ) { + self.images + .insert(name, ResolvedImage::Draft { blob_rkey, ident }); + } + + /// Add a published image. + pub fn add_published( + &mut self, + name: String, + entry_rkey: Rkey<'static>, + ident: AtIdentifier<'static>, + ) { + self.images + .insert(name, ResolvedImage::Published { entry_rkey, ident }); + } + + /// Check if an image is pending upload. + pub fn is_pending(&self, name: &str) -> bool { + matches!(self.images.get(name), Some(ResolvedImage::Pending(_))) + } + + /// Build a resolver from editor images and user identifier. + /// + /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included. + /// For published mode (entry_rkey=Some), all images are included. + pub fn from_images<'a>( + images: impl IntoIterator, + ident: AtIdentifier<'static>, + entry_rkey: Option>, + ) -> Self { + let mut resolver = Self::new(); + for editor_image in images { + // Get the name from the Image (use alt text as fallback if name is empty) + let name = editor_image + .image + .name + .as_ref() + .map(|n| n.to_string()) + .unwrap_or_else(|| editor_image.image.alt.to_string()); + + if name.is_empty() { + continue; + } + + match &entry_rkey { + // Published mode: use entry rkey for all images + Some(rkey) => { + resolver.add_published(name, rkey.clone(), ident.clone()); + } + // Draft mode: use published_blob_uri rkey + None => { + let blob_rkey = match &editor_image.published_blob_uri { + Some(uri) => match uri.rkey() { + Some(rkey) => rkey.0.clone().into_static(), + None => continue, + }, + None => continue, + }; + resolver.add_uploaded(name, blob_rkey, ident.clone()); + } + } + } + resolver + } +} + +impl ImageResolver for EditorImageResolver { + fn resolve_image_url(&self, url: &str) -> Option { + // Extract image name from /image/{name} format + let name = url.strip_prefix("/image/").unwrap_or(url); + + let resolved = self.images.get(name)?; + match resolved { + ResolvedImage::Pending(data_url) => Some(data_url.clone()), + ResolvedImage::Draft { blob_rkey, ident } => { + Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name)) + } + ResolvedImage::Published { entry_rkey, ident } => { + Some(format!("/image/{}/{}/{}", ident, entry_rkey, name)) + } + } + } +} + +// write_embed implementation +impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +where + I: Iterator, Range)>, + E: EmbedContentProvider, + R: ImageResolver, + W: WikilinkValidator, +{ + pub(crate) fn write_embed( + &mut self, + range: Range, + _embed_type: EmbedType, + dest_url: CowStr<'_>, + title: CowStr<'_>, + _id: CowStr<'_>, + attrs: Option>, + ) -> Result<(), fmt::Error> { + // Embed rendering: all syntax elements share one syn_id for visibility toggling + // Structure: ![[ url-as-link ]] + let raw_text = &self.source[range.clone()]; + let syn_id = self.gen_syn_id(); + let opening_char_start = self.last_char_offset; + + // Extract the URL from raw text (between ![[ and ]]) + let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") { + &raw_text[3..raw_text.len() - 2] + } else { + dest_url.as_ref() + }; + + // Calculate char positions + let url_char_len = url_text.chars().count(); + let opening_char_end = opening_char_start + 3; // "![[" + let url_char_start = opening_char_end; + let url_char_end = url_char_start + url_char_len; + let closing_char_start = url_char_end; + let closing_char_end = closing_char_start + 2; // "]]" + let formatted_range = opening_char_start..closing_char_end; + + // 1. Emit opening ![[ syntax span + if raw_text.starts_with("![[") { + write!( + &mut self.writer, + "![[", + syn_id, opening_char_start, opening_char_end + )?; + + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: opening_char_start..opening_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + + self.record_mapping( + range.start..range.start + 3, + opening_char_start..opening_char_end, + ); + } + + // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax) + let url = dest_url.as_ref(); + let link_href = if url.starts_with("at://") { + format!("https://alpha.weaver.sh/record/{}", url) + } else { + url.to_string() + }; + + write!( + &mut self.writer, + "", + link_href, syn_id, url_char_start, url_char_end + )?; + escape_html(&mut self.writer, url_text)?; + self.write("")?; + + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: url_char_start..url_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + + self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end); + + // 3. Emit closing ]] syntax span + if raw_text.ends_with("]]") { + write!( + &mut self.writer, + "]]", + syn_id, closing_char_start, closing_char_end + )?; + + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: closing_char_start..closing_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + + self.record_mapping( + range.end - 2..range.end, + closing_char_start..closing_char_end, + ); + } + + // Collect AT URI for later resolution + if url.starts_with("at://") || url.starts_with("did:") { + self.ref_collector.add_at_embed( + url, + if title.is_empty() { + None + } else { + Some(title.as_ref()) + }, + ); + } + + // 4. Emit the actual embed content + // Try to get content from attributes first + let content_from_attrs = if let Some(ref attrs) = attrs { + attrs + .attrs + .iter() + .find(|(k, _)| k.as_ref() == "content") + .map(|(_, v)| v.as_ref().to_string()) + } else { + None + }; + + // If no content in attrs, try provider + // Convert to owned to avoid borrow checker issues with self.write() + // TODO: figure out a way to do this that doesn't involve cloning + let content: Option = if content_from_attrs.is_some() { + content_from_attrs + } else if let Some(ref provider) = self.embed_provider { + provider.get_embed_html(url).map(|s| s.to_string()) + } else { + None + }; + + if let Some(ref html_content) = content { + // Write the pre-rendered content directly + self.write(html_content)?; + } else { + // Fallback: render as placeholder div + self.write("
")?; + self.write("Loading embed...")?; + self.write("
")?; + } + + // Consume the text events for the URL (they're still in the iterator) + // Use consume_until_end() since we already wrote the URL from source + self.consume_until_end(); + + // Update offsets + self.last_char_offset = closing_char_end; + self.last_byte_offset = range.end; + + Ok(()) + } +} diff --git a/crates/weaver-editor-core/src/writer/events.rs b/crates/weaver-editor-core/src/writer/events.rs new file mode 100644 index 0000000..56f20b9 --- /dev/null +++ b/crates/weaver-editor-core/src/writer/events.rs @@ -0,0 +1,686 @@ +//! Event processing for EditorWriter - the main run loop and event dispatch. + +use core::fmt; +use std::fmt::Write as _; +use std::ops::Range; + +use markdown_weaver::{Event, TagEnd}; +use markdown_weaver_escape::{escape_html, escape_html_body_text_with_char_count}; + +use crate::offset_map::OffsetMapping; +use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; +use crate::syntax::{SyntaxSpanInfo, SyntaxType}; + +use super::{EditorWriter, WriterResult}; + +// Main run loop +impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +where + I: Iterator, Range)>, + E: EmbedContentProvider, + R: ImageResolver, + W: WikilinkValidator, +{ + /// Process markdown events and write HTML. + /// + /// Returns offset mappings and paragraph boundaries. The HTML is written + /// to the writer passed in the constructor. + pub fn run(mut self) -> Result { + while let Some((event, range)) = self.events.next() { + tracing::trace!( + target: "weaver::writer", + event = ?event, + byte_range = ?range, + "processing event" + ); + + // For End events, emit any trailing content within the event's range + // BEFORE calling end_tag (which calls end_node and clears current_node_id) + // + // EXCEPTION: For inline formatting tags (Strong, Emphasis, Strikethrough), + // the closing syntax must be emitted AFTER the closing HTML tag, not before. + // Otherwise the closing `**` span ends up INSIDE the element. + // These tags handle their own closing syntax in end_tag(). + // Image and Embed handle ALL their syntax in the Start event, so exclude them too. + let is_self_handled_end = matches!( + &event, + Event::End( + TagEnd::Strong + | TagEnd::Emphasis + | TagEnd::Strikethrough + | TagEnd::Image + | TagEnd::Embed + ) + ); + + if matches!(&event, Event::End(_)) && !is_self_handled_end { + // Emit gap from last_byte_offset to range.end + self.emit_gap_before(range.end)?; + } else if !matches!(&event, Event::End(_)) { + // For other events, emit any gap before range.start + // (emit_syntax handles char offset tracking) + self.emit_gap_before(range.start)?; + } + // For inline format End events, gap is emitted inside end_tag() AFTER the closing HTML + + // Store last_byte before processing + let last_byte_before = self.last_byte_offset; + + // Process the event (passing range for tag syntax) + self.process_event(event, range.clone())?; + + // Update tracking - but don't override if start_tag manually updated it + // (for inline formatting tags that emit opening syntax) + if self.last_byte_offset == last_byte_before { + // Event didn't update offset, so we update it + self.last_byte_offset = range.end; + } + // else: Event updated offset (e.g. start_tag emitted opening syntax), keep that value + } + + // Emit any trailing syntax + self.emit_gap_before(self.source.len())?; + + // Handle unmapped trailing content (stripped by parser) + // This includes trailing spaces that markdown ignores + let doc_byte_len = self.source.len(); + let doc_char_len = self.source_len_chars; + + if self.last_byte_offset < doc_byte_len || self.last_char_offset < doc_char_len { + // Emit the trailing content as visible syntax + if self.last_byte_offset < doc_byte_len { + let trailing = &self.source[self.last_byte_offset..]; + if !trailing.is_empty() { + let char_start = self.last_char_offset; + let trailing_char_len = trailing.chars().count(); + + let char_end = char_start + trailing_char_len; + let syn_id = self.gen_syn_id(); + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, trailing)?; + self.write("")?; + + // Record mapping if we have a node + if let Some(ref node_id) = self.current_node.id { + let mapping = OffsetMapping { + byte_range: self.last_byte_offset..doc_byte_len, + char_range: char_start..char_end, + node_id: node_id.clone(), + char_offset_in_node: self.current_node.char_offset, + child_index: None, + utf16_len: trailing_char_len, // visible + }; + self.current_para.offset_maps.push(mapping); + self.current_node.char_offset += trailing_char_len; + } + + self.last_char_offset = char_start + trailing_char_len; + } + } + } + + // Add any remaining accumulated data for the last paragraph + // (content that wasn't followed by a paragraph boundary) + if !self.current_para.offset_maps.is_empty() + || !self.current_para.syntax_spans.is_empty() + || !self.ref_collector.refs.is_empty() + { + self.offset_maps_by_para + .push(std::mem::take(&mut self.current_para.offset_maps)); + self.syntax_spans_by_para + .push(std::mem::take(&mut self.current_para.syntax_spans)); + self.refs_by_para + .push(std::mem::take(&mut self.ref_collector.refs)); + } + + // Get HTML segments from writer + let html_segments = self.writer.into_segments(); + + Ok(WriterResult { + html_segments, + offset_maps_by_paragraph: self.offset_maps_by_para, + paragraph_ranges: self.paragraphs.ranges, + syntax_spans_by_paragraph: self.syntax_spans_by_para, + collected_refs_by_paragraph: self.refs_by_para, + }) + } + + fn process_event(&mut self, event: Event<'_>, range: Range) -> Result<(), fmt::Error> { + use Event::*; + + match event { + Start(tag) => self.start_tag(tag, range)?, + End(tag) => self.end_tag(tag, range)?, + Text(text) => { + // If buffering code, append to buffer instead of writing + if let Some((_, ref mut content)) = self.code_block.buffer { + content.push_str(&text); + + // Track byte and char ranges for code block content + let text_char_len = text.chars().count(); + let text_byte_len = text.len(); + if let Some(ref mut code_byte_range) = self.code_block.byte_range { + // Extend existing ranges + code_byte_range.end = range.end; + if let Some(ref mut code_char_range) = self.code_block.char_range { + code_char_range.end = self.last_char_offset + text_char_len; + } + } else { + // First text in code block - start tracking + self.code_block.byte_range = Some(range.clone()); + self.code_block.char_range = + Some(self.last_char_offset..self.last_char_offset + text_char_len); + } + // Update offsets so paragraph boundary is correct + self.last_char_offset += text_char_len; + self.last_byte_offset += text_byte_len; + } else if !self.in_non_writing_block { + // Escape HTML and count chars in one pass + let char_start = self.last_char_offset; + let text_char_len = + escape_html_body_text_with_char_count(&mut self.writer, &text)?; + let char_end = char_start + text_char_len; + + // Text becomes a text node child of the current container + if text_char_len > 0 { + self.current_node.child_count += 1; + } + + // Record offset mapping + self.record_mapping(range.clone(), char_start..char_end); + + // Update char offset tracking + self.last_char_offset = char_end; + self.end_newline = text.ends_with('\n'); + } + } + Code(text) => { + let format_start = self.last_char_offset; + let raw_text = &self.source[range.clone()]; + + // Track opening span index so we can set formatted_range later + let opening_span_idx = if raw_text.starts_with('`') { + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let backtick_char_end = char_start + 1; + write!( + &mut self.writer, + "`", + syn_id, char_start, backtick_char_end + )?; + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..backtick_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, // Set after we know the full range + }); + self.last_char_offset += 1; + Some(self.current_para.syntax_spans.len() - 1) + } else { + None + }; + + self.write("")?; + + // Track offset mapping for code content + let content_char_start = self.last_char_offset; + let text_char_len = + escape_html_body_text_with_char_count(&mut self.writer, &text)?; + let content_char_end = content_char_start + text_char_len; + + // Record offset mapping (code content is visible) + self.record_mapping(range.clone(), content_char_start..content_char_end); + self.last_char_offset = content_char_end; + + self.write("")?; + + // Emit closing backtick and track it + if raw_text.ends_with('`') { + let syn_id = self.gen_syn_id(); + let backtick_char_start = self.last_char_offset; + let backtick_char_end = backtick_char_start + 1; + write!( + &mut self.writer, + "`", + syn_id, backtick_char_start, backtick_char_end + )?; + + // Now we know the full formatted range + let formatted_range = format_start..backtick_char_end; + + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: backtick_char_start..backtick_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + + // Update opening span with formatted_range + if let Some(idx) = opening_span_idx { + self.current_para.syntax_spans[idx].formatted_range = + Some(formatted_range); + } + + self.last_char_offset += 1; + } + } + InlineMath(text) => { + self.process_inline_math(&text, range)?; + } + DisplayMath(text) => { + self.process_display_math(&text, range)?; + } + Html(html) => { + // Track offset mapping for raw HTML + let char_start = self.last_char_offset; + let html_char_len = html.chars().count(); + let char_end = char_start + html_char_len; + + self.write(&html)?; + + // Record mapping for inline HTML + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + } + InlineHtml(html) => { + // Track offset mapping for raw HTML + let char_start = self.last_char_offset; + let html_char_len = html.chars().count(); + let char_end = char_start + html_char_len; + self.write(r#""#)?; + self.write(&html)?; + self.write("")?; + // Record mapping for inline HTML + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + } + SoftBreak => { + // Emit
for visual line break, plus a space for cursor positioning. + // This space maps to the \n so the cursor can land here when navigating. + let char_start = self.last_char_offset; + + // Emit
+ self.write("
")?; + self.current_node.child_count += 1; + + // Emit space for cursor positioning - this gives the browser somewhere + // to place the cursor when navigating to this line + self.write("\u{200B}")?; + self.current_node.child_count += 1; + + // Map the space to the newline position - cursor landing here means + // we're at the end of the line (after the \n) + if let Some(ref node_id) = self.current_node.id { + let mapping = OffsetMapping { + byte_range: range.clone(), + char_range: char_start..char_start + 1, + node_id: node_id.clone(), + char_offset_in_node: self.current_node.char_offset, + child_index: None, + utf16_len: 1, // the space we emitted + }; + self.current_para.offset_maps.push(mapping); + self.current_node.char_offset += 1; + } + + self.last_char_offset = char_start + 1; // +1 for the \n + } + HardBreak => { + // Emit the two spaces as visible (dimmed) text, then
+ let gap = &self.source[range.clone()]; + if gap.ends_with('\n') { + let spaces = &gap[..gap.len() - 1]; // everything except the \n + let char_start = self.last_char_offset; + let spaces_char_len = spaces.chars().count(); + let char_end = char_start + spaces_char_len; + + // Emit and map the visible spaces + let syn_id = self.gen_syn_id(); + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, spaces)?; + self.write("")?; + + // Count this span as a child + self.current_node.child_count += 1; + + self.record_mapping( + range.start..range.start + spaces.len(), + char_start..char_end, + ); + + // Now the actual line break
+ self.write("
")?; + + // Count the
as a child + self.current_node.child_count += 1; + + // After
, emit plain zero-width space for cursor positioning + self.write("\u{200B}")?; + + // Count the zero-width space text node as a child + self.current_node.child_count += 1; + + // Map the newline position to the zero-width space text node + if let Some(ref node_id) = self.current_node.id { + let newline_char_offset = char_start + spaces_char_len; + let mapping = OffsetMapping { + byte_range: range.start + spaces.len()..range.end, + char_range: newline_char_offset..newline_char_offset + 1, + node_id: node_id.clone(), + char_offset_in_node: self.current_node.char_offset, + child_index: None, // text node - TreeWalker will find it + utf16_len: 1, // zero-width space is 1 UTF-16 unit + }; + self.current_para.offset_maps.push(mapping); + + // Increment char offset - TreeWalker will encounter this text node + self.current_node.char_offset += 1; + } + + self.last_char_offset = char_start + spaces_char_len + 1; // +1 for \n + } else { + // Fallback: just
+ self.write("
")?; + } + } + Rule => { + if !self.end_newline { + self.write("\n")?; + } + + // Emit syntax span before the rendered element + if range.start < range.end { + let raw_text = &self.source[range]; + let trimmed = raw_text.trim(); + if !trimmed.is_empty() { + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_len = trimmed.chars().count(); + let char_end = char_start + char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, trimmed)?; + self.write("")?; + + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range: None, + }); + } + } + + // Wrap
in toggle-block for future cursor-based toggling + self.write("

")?; + } + FootnoteReference(name) => { + // Emit [^name] as styled (but NOT hidden) inline span + let raw_text = &self.source[range.clone()]; + let char_start = self.last_char_offset; + let syntax_char_len = raw_text.chars().count(); + let char_end = char_start + syntax_char_len; + + // Use footnote-ref class for styling, not md-syntax-inline (which hides) + write!( + &mut self.writer, + "", + char_start, char_end, name + )?; + escape_html(&mut self.writer, raw_text)?; + self.write("")?; + + // Record offset mapping + self.record_mapping(range.clone(), char_start..char_end); + + // Count as child + self.current_node.child_count += 1; + + // Update tracking + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + } + TaskListMarker(checked) => { + // Emit the [ ] or [x] syntax + if range.start < range.end { + let raw_text = &self.source[range]; + if let Some(bracket_pos) = raw_text.find('[') { + let end_pos = raw_text.find(']').map(|p| p + 1).unwrap_or(bracket_pos + 3); + let syntax = &raw_text[bracket_pos..end_pos.min(raw_text.len())]; + + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, syntax)?; + self.write(" ")?; + + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, + }); + } + } + + if checked { + self.write("")?; + } else { + self.write("")?; + } + } + WeaverBlock(text) => { + // Buffer WeaverBlock content for parsing on End + self.weaver_block.buffer.push_str(&text); + } + } + Ok(()) + } + + /// Process inline math ($...$) + fn process_inline_math(&mut self, text: &str, range: Range) -> Result<(), fmt::Error> { + let raw_text = &self.source[range.clone()]; + let syn_id = self.gen_syn_id(); + let opening_char_start = self.last_char_offset; + + // Calculate char positions + let text_char_len = text.chars().count(); + let opening_char_end = opening_char_start + 1; // "$" + let content_char_start = opening_char_end; + let content_char_end = content_char_start + text_char_len; + let closing_char_start = content_char_end; + let closing_char_end = closing_char_start + 1; // "$" + let formatted_range = opening_char_start..closing_char_end; + + // 1. Emit opening $ syntax span + if raw_text.starts_with('$') { + write!( + &mut self.writer, + "$", + syn_id, opening_char_start, opening_char_end + )?; + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: opening_char_start..opening_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + self.record_mapping( + range.start..range.start + 1, + opening_char_start..opening_char_end, + ); + } + + // 2. Emit raw LaTeX content (hidden with syntax when cursor outside) + write!( + &mut self.writer, + "", + syn_id, content_char_start, content_char_end + )?; + escape_html(&mut self.writer, text)?; + self.write("")?; + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: content_char_start..content_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + self.record_mapping( + range.start + 1..range.end - 1, + content_char_start..content_char_end, + ); + + // 3. Emit closing $ syntax span + if raw_text.ends_with('$') { + write!( + &mut self.writer, + "$", + syn_id, closing_char_start, closing_char_end + )?; + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: closing_char_start..closing_char_end, + syntax_type: SyntaxType::Inline, + formatted_range: Some(formatted_range.clone()), + }); + self.record_mapping( + range.end - 1..range.end, + closing_char_start..closing_char_end, + ); + } + + // 4. Emit rendered MathML (always visible, not tied to syn_id) + // Include data-char-target so clicking moves cursor into the math region + // contenteditable="false" so DOM walker skips this for offset counting + match weaver_renderer::math::render_math(text, false) { + weaver_renderer::math::MathResult::Success(mathml) => { + write!( + &mut self.writer, + "{}", + content_char_start, mathml + )?; + } + weaver_renderer::math::MathResult::Error { html, .. } => { + // Show error indicator (also always visible) + self.write(&html)?; + } + } + + self.last_char_offset = closing_char_end; + Ok(()) + } + + /// Process display math ($$...$$) + fn process_display_math(&mut self, text: &str, range: Range) -> Result<(), fmt::Error> { + let raw_text = &self.source[range.clone()]; + let syn_id = self.gen_syn_id(); + let opening_char_start = self.last_char_offset; + + // Calculate char positions + let text_char_len = text.chars().count(); + let opening_char_end = opening_char_start + 2; // "$$" + let content_char_start = opening_char_end; + let content_char_end = content_char_start + text_char_len; + let closing_char_start = content_char_end; + let closing_char_end = closing_char_start + 2; // "$$" + let formatted_range = opening_char_start..closing_char_end; + + // 1. Emit opening $$ syntax span + // Use Block syntax type so visibility is based on "cursor in same paragraph" + if raw_text.starts_with("$$") { + write!( + &mut self.writer, + "$$", + syn_id, opening_char_start, opening_char_end + )?; + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: opening_char_start..opening_char_end, + syntax_type: SyntaxType::Block, + formatted_range: Some(formatted_range.clone()), + }); + self.record_mapping( + range.start..range.start + 2, + opening_char_start..opening_char_end, + ); + } + + // 2. Emit raw LaTeX content (hidden with syntax when cursor outside) + write!( + &mut self.writer, + "", + syn_id, content_char_start, content_char_end + )?; + escape_html(&mut self.writer, text)?; + self.write("")?; + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: content_char_start..content_char_end, + syntax_type: SyntaxType::Block, + formatted_range: Some(formatted_range.clone()), + }); + self.record_mapping( + range.start + 2..range.end - 2, + content_char_start..content_char_end, + ); + + // 3. Emit closing $$ syntax span + if raw_text.ends_with("$$") { + write!( + &mut self.writer, + "$$", + syn_id, closing_char_start, closing_char_end + )?; + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: closing_char_start..closing_char_end, + syntax_type: SyntaxType::Block, + formatted_range: Some(formatted_range.clone()), + }); + self.record_mapping( + range.end - 2..range.end, + closing_char_start..closing_char_end, + ); + } + + // 4. Emit rendered MathML (always visible, not tied to syn_id) + // Include data-char-target so clicking moves cursor into the math region + // contenteditable="false" so DOM walker skips this for offset counting + match weaver_renderer::math::render_math(text, true) { + weaver_renderer::math::MathResult::Success(mathml) => { + write!( + &mut self.writer, + "{}", + content_char_start, mathml + )?; + } + weaver_renderer::math::MathResult::Error { html, .. } => { + // Show error indicator (also always visible) + self.write(&html)?; + } + } + + self.last_char_offset = closing_char_end; + Ok(()) + } +} diff --git a/crates/weaver-editor-core/src/writer/mod.rs b/crates/weaver-editor-core/src/writer/mod.rs new file mode 100644 index 0000000..7e5475b --- /dev/null +++ b/crates/weaver-editor-core/src/writer/mod.rs @@ -0,0 +1,459 @@ +//! EditorWriter - HTML generation for markdown with visible formatting. +//! +//! Refactored to use grouped state structs for clarity. +//! Generic over TextBuffer - works with ropey (local) or can be adapted for Loro (collab). + +mod embed; +mod events; +mod state; +mod syntax; +mod tags; + +pub use embed::EditorImageResolver; +pub use state::*; + +use std::collections::HashMap; +use std::fmt::{self, Write as FmtWrite}; +use std::ops::Range; + +use markdown_weaver::Event; +use smol_str::SmolStr; + +use crate::offset_map::OffsetMapping; +use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; +use crate::syntax::SyntaxSpanInfo; + +/// Result of rendering with EditorWriter. +#[derive(Debug, Clone, Default)] +pub struct WriterResult { + /// HTML segments, one per paragraph + pub html_segments: Vec, + /// Offset mappings per paragraph + pub offset_maps_by_paragraph: Vec>, + /// Paragraph boundaries: (byte_range, char_range) + pub paragraph_ranges: Vec<(Range, Range)>, + /// Syntax spans per paragraph + pub syntax_spans_by_paragraph: Vec>, + /// Collected refs per paragraph + pub collected_refs_by_paragraph: Vec>, +} + +/// Segmented HTML output writer. +#[derive(Debug, Clone, Default)] +pub struct SegmentedWriter { + segments: Vec, + current: String, +} + +impl SegmentedWriter { + pub fn new() -> Self { + Self::default() + } + + pub fn write_str(&mut self, s: &str) -> fmt::Result { + self.current.push_str(s); + Ok(()) + } + + pub fn new_segment(&mut self) { + if !self.current.is_empty() { + self.segments.push(std::mem::take(&mut self.current)); + } + } + + pub fn into_segments(mut self) -> Vec { + self.new_segment(); + self.segments + } + + pub fn current_len(&self) -> usize { + self.current.len() + } +} + +impl FmtWrite for SegmentedWriter { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.current.push_str(s); + Ok(()) + } +} + +impl markdown_weaver_escape::StrWrite for SegmentedWriter { + type Error = fmt::Error; + + fn write_str(&mut self, s: &str) -> fmt::Result { + self.current.push_str(s); + Ok(()) + } + + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { + std::fmt::Write::write_fmt(&mut self.current, args) + } +} + +/// HTML writer that preserves markdown formatting characters. +/// +/// Generic over: +/// - `I`: Iterator of markdown events with byte ranges +/// - `E`: Embed content provider (optional) +/// - `R`: Image resolver (optional) +/// - `W`: Wikilink validator (optional) +pub struct EditorWriter<'a, I, E = (), R = (), W = ()> +where + I: Iterator, Range)>, +{ + // === Input === + source: &'a str, + source_len_chars: usize, + events: I, + + // === Output === + writer: SegmentedWriter, + + // === Position tracking === + last_byte_offset: usize, + last_char_offset: usize, + + // === Rendering flags === + end_newline: bool, + in_non_writing_block: bool, + + // === Grouped state === + pub(crate) table: TableContext, + pub(crate) code_block: CodeBlockContext, + pub(crate) node_ids: NodeIdGenerator, + pub(crate) current_node: CurrentNodeState, + pub(crate) paragraphs: ParagraphTracker, + pub(crate) current_para: ParagraphBuildState, + pub(crate) weaver_block: WeaverBlockContext, + pub(crate) footnotes: FootnoteContext, + pub(crate) utf16: Utf16Tracker, + + // === Per-paragraph results === + offset_maps_by_para: Vec>, + syntax_spans_by_para: Vec>, + refs_by_para: Vec>, + + // === External resolvers === + embed_provider: Option, + image_resolver: Option, + wikilink_validator: Option, + entry_index: Option<&'a weaver_common::EntryIndex>, + + // === Misc === + numbers: HashMap, + pending_blockquote_range: Option>, + ref_collector: weaver_common::RefCollector, +} + +impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +where + I: Iterator, Range)>, +{ + /// Create a new EditorWriter. + /// + /// `source` is the markdown source text. + /// `source_len_chars` is the length in Unicode chars (for bounds checking). + /// `events` is the markdown parser event iterator. + pub fn new(source: &'a str, source_len_chars: usize, events: I) -> Self { + Self { + source, + source_len_chars, + events, + writer: SegmentedWriter::new(), + last_byte_offset: 0, + last_char_offset: 0, + end_newline: true, + in_non_writing_block: false, + table: TableContext::default(), + code_block: CodeBlockContext::default(), + node_ids: NodeIdGenerator::default(), + current_node: CurrentNodeState::default(), + paragraphs: ParagraphTracker::default(), + current_para: ParagraphBuildState::default(), + weaver_block: WeaverBlockContext::default(), + footnotes: FootnoteContext::default(), + utf16: Utf16Tracker::new(), + offset_maps_by_para: Vec::new(), + syntax_spans_by_para: Vec::new(), + refs_by_para: Vec::new(), + embed_provider: None, + image_resolver: None, + wikilink_validator: None, + entry_index: None, + numbers: HashMap::new(), + pending_blockquote_range: None, + ref_collector: weaver_common::RefCollector::new(), + } + } + + /// Set a static node ID prefix for all paragraphs. + pub fn with_node_id_prefix(mut self, prefix: &str) -> Self { + self.node_ids.prefix = Some(SmolStr::new(prefix)); + self.node_ids.next_node_id = 0; + self + } + + /// Use auto-incrementing paragraph prefixes starting from `base`. + pub fn with_auto_incrementing_prefix(mut self, base: usize) -> Self { + use smol_str::format_smolstr; + self.node_ids.auto_increment_base = Some(base); + self.node_ids.prefix = Some(format_smolstr!("p-{}", base)); + self.node_ids.next_node_id = 0; + self + } + + /// Override prefix for a specific paragraph index. + pub fn with_static_prefix_at_index(mut self, index: usize, prefix: &str) -> Self { + self.node_ids.static_override = Some((index, SmolStr::new(prefix))); + if index == 0 { + self.node_ids.prefix = Some(SmolStr::new(prefix)); + self.node_ids.next_node_id = 0; + } + self + } + + /// Set initial offsets (for rendering a subset of the document). + pub fn with_offsets( + mut self, + byte_offset: usize, + char_offset: usize, + node_id_offset: usize, + syn_id_offset: usize, + ) -> Self { + self.last_byte_offset = byte_offset; + self.last_char_offset = char_offset; + self.node_ids.next_node_id = node_id_offset; + self.node_ids.next_syn_id = syn_id_offset; + self + } + + /// Set embed content provider. + pub fn with_embed_provider( + self, + provider: E2, + ) -> EditorWriter<'a, I, E2, R, W> { + EditorWriter { + source: self.source, + source_len_chars: self.source_len_chars, + events: self.events, + writer: self.writer, + last_byte_offset: self.last_byte_offset, + last_char_offset: self.last_char_offset, + end_newline: self.end_newline, + in_non_writing_block: self.in_non_writing_block, + table: self.table, + code_block: self.code_block, + node_ids: self.node_ids, + current_node: self.current_node, + paragraphs: self.paragraphs, + current_para: self.current_para, + weaver_block: self.weaver_block, + footnotes: self.footnotes, + utf16: self.utf16, + offset_maps_by_para: self.offset_maps_by_para, + syntax_spans_by_para: self.syntax_spans_by_para, + refs_by_para: self.refs_by_para, + embed_provider: Some(provider), + image_resolver: self.image_resolver, + wikilink_validator: self.wikilink_validator, + entry_index: self.entry_index, + numbers: self.numbers, + pending_blockquote_range: self.pending_blockquote_range, + ref_collector: self.ref_collector, + } + } + + /// Set image resolver. + pub fn with_image_resolver( + self, + resolver: R2, + ) -> EditorWriter<'a, I, E, R2, W> { + EditorWriter { + source: self.source, + source_len_chars: self.source_len_chars, + events: self.events, + writer: self.writer, + last_byte_offset: self.last_byte_offset, + last_char_offset: self.last_char_offset, + end_newline: self.end_newline, + in_non_writing_block: self.in_non_writing_block, + table: self.table, + code_block: self.code_block, + node_ids: self.node_ids, + current_node: self.current_node, + paragraphs: self.paragraphs, + current_para: self.current_para, + weaver_block: self.weaver_block, + footnotes: self.footnotes, + utf16: self.utf16, + offset_maps_by_para: self.offset_maps_by_para, + syntax_spans_by_para: self.syntax_spans_by_para, + refs_by_para: self.refs_by_para, + embed_provider: self.embed_provider, + image_resolver: Some(resolver), + wikilink_validator: self.wikilink_validator, + entry_index: self.entry_index, + numbers: self.numbers, + pending_blockquote_range: self.pending_blockquote_range, + ref_collector: self.ref_collector, + } + } + + /// Set wikilink validator. + pub fn with_wikilink_validator( + self, + validator: W2, + ) -> EditorWriter<'a, I, E, R, W2> { + EditorWriter { + source: self.source, + source_len_chars: self.source_len_chars, + events: self.events, + writer: self.writer, + last_byte_offset: self.last_byte_offset, + last_char_offset: self.last_char_offset, + end_newline: self.end_newline, + in_non_writing_block: self.in_non_writing_block, + table: self.table, + code_block: self.code_block, + node_ids: self.node_ids, + current_node: self.current_node, + paragraphs: self.paragraphs, + current_para: self.current_para, + weaver_block: self.weaver_block, + footnotes: self.footnotes, + utf16: self.utf16, + offset_maps_by_para: self.offset_maps_by_para, + syntax_spans_by_para: self.syntax_spans_by_para, + refs_by_para: self.refs_by_para, + embed_provider: self.embed_provider, + image_resolver: self.image_resolver, + wikilink_validator: Some(validator), + entry_index: self.entry_index, + numbers: self.numbers, + pending_blockquote_range: self.pending_blockquote_range, + ref_collector: self.ref_collector, + } + } + + /// Set entry index for wikilink resolution. + pub fn with_entry_index(mut self, index: &'a weaver_common::EntryIndex) -> Self { + self.entry_index = Some(index); + self + } +} + +// Core helper methods +impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +where + I: Iterator, Range)>, +{ + /// Write a string to the output. + #[inline] + pub(crate) fn write(&mut self, s: &str) -> fmt::Result { + if !s.is_empty() { + self.end_newline = s.ends_with('\n'); + } + self.writer.write_str(s) + } + + /// Write a newline. + #[inline] + pub(crate) fn write_newline(&mut self) -> fmt::Result { + self.end_newline = true; + self.writer.write_str("\n") + } + + /// Generate a unique node ID. + pub(crate) fn gen_node_id(&mut self) -> SmolStr { + self.node_ids.next_node() + } + + /// Generate a unique syntax span ID. + pub(crate) fn gen_syn_id(&mut self) -> SmolStr { + self.node_ids.next_syn() + } + + /// Start tracking a new text container node. + pub(crate) fn begin_node(&mut self, node_id: SmolStr) { + self.current_node.begin(node_id); + } + + /// Stop tracking current node. + pub(crate) fn end_node(&mut self) { + self.current_node.end(); + } + + /// Compute UTF-16 length for a text slice (fast path for ASCII). + #[inline] + pub(crate) fn utf16_len_for_slice(text: &str) -> usize { + let byte_len = text.len(); + let char_len = text.chars().count(); + + if byte_len == char_len { + char_len + } else { + text.encode_utf16().count() + } + } + + /// Record an offset mapping. + pub(crate) fn record_mapping(&mut self, byte_range: Range, char_range: Range) { + if let Some(ref node_id) = self.current_node.id { + let text_slice = &self.source[byte_range.clone()]; + let utf16_len = Self::utf16_len_for_slice(text_slice); + + // Record UTF-16 checkpoint + let last = self.utf16.last(); + let new_utf16 = last.1 + utf16_len; + if char_range.end > last.0 { + self.utf16.checkpoint(char_range.end, new_utf16); + } + + let mapping = OffsetMapping { + byte_range, + char_range: char_range.clone(), + node_id: node_id.clone(), + char_offset_in_node: self.current_node.char_offset, + child_index: None, + utf16_len, + }; + self.current_para.offset_maps.push(mapping); + self.current_node.char_offset += utf16_len; + } + } + + /// Finalize the current paragraph. + pub(crate) fn finalize_paragraph( + &mut self, + byte_range: Range, + char_range: Range, + ) { + self.paragraphs.ranges.push((byte_range, char_range)); + + let (maps, spans, refs) = self.current_para.take_all(); + self.offset_maps_by_para.push(maps); + self.syntax_spans_by_para.push(spans); + self.refs_by_para.push(refs); + + self.node_ids.next_paragraph(); + self.writer.new_segment(); + } + + /// Consume events until End tag without writing. + pub(crate) fn consume_until_end(&mut self) { + let mut nest = 0; + while let Some((event, _)) = self.events.next() { + match event { + Event::Start(_) => nest += 1, + Event::End(_) => { + if nest == 0 { + break; + } + nest -= 1; + } + _ => {} + } + } + } +} diff --git a/crates/weaver-editor-core/src/writer/state.rs b/crates/weaver-editor-core/src/writer/state.rs new file mode 100644 index 0000000..1f16d3a --- /dev/null +++ b/crates/weaver-editor-core/src/writer/state.rs @@ -0,0 +1,358 @@ +//! State structures for EditorWriter, grouped by concern. + +use std::collections::HashMap; +use std::ops::Range; + +use markdown_weaver::Alignment; +use smol_str::{SmolStr, ToSmolStr, format_smolstr}; + +use crate::offset_map::OffsetMapping; +use crate::syntax::{SyntaxSpanInfo, SyntaxType}; + +/// Table rendering state. +#[derive(Debug, Clone, Default)] +pub struct TableContext { + pub state: TableState, + pub alignments: Vec, + pub cell_index: usize, + pub render_as_markdown: bool, + pub start_offset: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum TableState { + #[default] + Head, + Body, +} + +/// Code block buffering state. +#[derive(Debug, Clone, Default)] +pub struct CodeBlockContext { + /// (language, content) being buffered + pub buffer: Option<(Option, String)>, + /// Byte range of buffered content + pub byte_range: Option>, + /// Char range of buffered content + pub char_range: Option>, + /// Char offset where code block started + pub block_start: Option, + /// Index of opening fence syntax span + pub opening_span_idx: Option, +} + +impl CodeBlockContext { + pub fn is_active(&self) -> bool { + self.buffer.is_some() + } + + pub fn clear(&mut self) { + *self = Self::default(); + } +} + +/// Node ID generation for DOM element IDs. +#[derive(Debug, Clone)] +pub struct NodeIdGenerator { + /// Paragraph ID prefix (e.g., "p-0") + pub prefix: Option, + /// Auto-increment base for paragraph prefixes + pub auto_increment_base: Option, + /// Override for specific paragraph index + pub static_override: Option<(usize, SmolStr)>, + /// Current paragraph index (0-indexed) + pub current_paragraph: usize, + /// Next node ID counter within paragraph + pub next_node_id: usize, + /// Next syntax span ID counter + pub next_syn_id: usize, +} + +impl Default for NodeIdGenerator { + fn default() -> Self { + Self { + prefix: None, + auto_increment_base: None, + static_override: None, + current_paragraph: 0, + next_node_id: 0, + next_syn_id: 0, + } + } +} + +impl NodeIdGenerator { + /// Get the current paragraph prefix. + pub fn current_prefix(&self) -> SmolStr { + if let Some((idx, ref prefix)) = self.static_override { + if idx == self.current_paragraph { + return prefix.clone(); + } + } + if let Some(base) = self.auto_increment_base { + return format_smolstr!("p-{}", base + self.current_paragraph); + } + self.prefix.clone().unwrap_or_else(|| "p-0".to_smolstr()) + } + + /// Generate a node ID (e.g., "p-0-n3") + pub fn next_node(&mut self) -> SmolStr { + let id = if let Some(ref prefix) = self.prefix { + format_smolstr!("{}-n{}", prefix, self.next_node_id) + } else { + format_smolstr!("n{}", self.next_node_id) + }; + self.next_node_id += 1; + SmolStr::new(id) + } + + /// Generate a syntax span ID (e.g., "s5") + pub fn next_syn(&mut self) -> SmolStr { + let id = format_smolstr!("s{}", self.next_syn_id); + self.next_syn_id += 1; + SmolStr::new(id) + } + + /// Advance to next paragraph. + pub fn next_paragraph(&mut self) { + self.current_paragraph += 1; + self.next_node_id = 0; + + // Update prefix for next paragraph + if let Some((override_idx, ref override_prefix)) = self.static_override { + if self.current_paragraph == override_idx { + self.prefix = Some(override_prefix.clone()); + } else if let Some(base) = self.auto_increment_base { + self.prefix = Some(format_smolstr!("p-{}", base + self.current_paragraph)); + } + } else if let Some(base) = self.auto_increment_base { + self.prefix = Some(format_smolstr!("p-{}", base + self.current_paragraph)); + } + } +} + +/// Current DOM node tracking for offset mapping. +#[derive(Debug, Clone, Default)] +pub struct CurrentNodeState { + /// Node ID for current text container + pub id: Option, + /// UTF-16 offset within current node + pub char_offset: usize, + /// Number of child elements in current container + pub child_count: usize, +} + +impl CurrentNodeState { + pub fn begin(&mut self, id: SmolStr) { + self.id = Some(id); + self.char_offset = 0; + self.child_count = 0; + } + + pub fn end(&mut self) { + self.id = None; + self.char_offset = 0; + self.child_count = 0; + } +} + +/// Paragraph boundary tracking. +#[derive(Debug, Clone, Default)] +pub struct ParagraphTracker { + /// Completed paragraph ranges: (byte_range, char_range) + pub ranges: Vec<(Range, Range)>, + /// Start of current paragraph: (byte_offset, char_offset) + pub current_start: Option<(usize, usize)>, + /// List nesting depth (suppress paragraph boundaries inside lists) + pub list_depth: usize, + /// In footnote definition (suppress inner paragraph boundaries) + pub in_footnote_def: bool, +} + +impl ParagraphTracker { + pub fn start_paragraph(&mut self, byte_offset: usize, char_offset: usize) { + self.current_start = Some((byte_offset, char_offset)); + } + + pub fn end_paragraph( + &mut self, + byte_offset: usize, + char_offset: usize, + ) -> Option<(Range, Range)> { + if let Some((start_byte, start_char)) = self.current_start.take() { + let ranges = (start_byte..byte_offset, start_char..char_offset); + self.ranges.push(ranges.clone()); + Some(ranges) + } else { + None + } + } + + pub fn in_list(&self) -> bool { + self.list_depth > 0 + } + + pub fn should_track_boundaries(&self) -> bool { + self.list_depth == 0 && !self.in_footnote_def + } +} + +/// Current paragraph build state (offset maps, syntax spans, refs). +#[derive(Debug, Clone, Default)] +pub struct ParagraphBuildState { + /// Offset mappings for current paragraph + pub offset_maps: Vec, + /// Syntax spans for current paragraph + pub syntax_spans: Vec, + /// Collected refs for current paragraph + pub collected_refs: Vec, + /// Stack of pending inline formats: (syn_id, char_start) + pub pending_inline_formats: Vec<(SmolStr, usize)>, +} + +impl ParagraphBuildState { + pub fn take_all( + &mut self, + ) -> ( + Vec, + Vec, + Vec, + ) { + ( + std::mem::take(&mut self.offset_maps), + std::mem::take(&mut self.syntax_spans), + std::mem::take(&mut self.collected_refs), + ) + } + + /// Finalize a paired inline format (Strong, Emphasis, Strikethrough). + pub fn finalize_paired_format(&mut self, last_char_offset: usize) { + if let Some((opening_syn_id, format_start)) = self.pending_inline_formats.pop() { + let formatted_range = format_start..last_char_offset; + + // Update opening span + if let Some(span) = self + .syntax_spans + .iter_mut() + .find(|s| s.syn_id == opening_syn_id) + { + span.formatted_range = Some(formatted_range.clone()); + } + + // Update closing span (most recent) + if let Some(closing) = self.syntax_spans.last_mut() { + if closing.syntax_type == SyntaxType::Inline { + closing.formatted_range = Some(formatted_range); + } + } + } + } +} + +/// WeaverBlock prefix system state. +#[derive(Debug, Clone, Default)] +pub struct WeaverBlockContext { + /// Pending attrs to apply to next block element + pub pending_attrs: Option>, + /// Type of wrapper element currently open + pub active_wrapper: Option, + /// Buffer for WeaverBlock text content + pub buffer: String, + /// Start char offset of current WeaverBlock + pub char_start: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WrapperElement { + Aside, + Div, +} + +/// Footnote reference/definition linking state. +#[derive(Debug, Clone, Default)] +pub struct FootnoteContext { + /// Maps footnote name -> (syntax_span_index, char_start) + pub ref_spans: HashMap, + /// Current footnote def being processed: (name, span_idx, char_start) + pub current_def: Option<(String, usize, usize)>, +} + +/// UTF-16 offset checkpoints for incremental tracking. +#[derive(Debug, Clone, Default)] +pub struct Utf16Tracker { + /// Checkpoints: (char_offset, utf16_offset) + pub checkpoints: Vec<(usize, usize)>, +} + +impl Utf16Tracker { + pub fn new() -> Self { + Self { + checkpoints: vec![(0, 0)], + } + } + + /// Add a checkpoint. + pub fn checkpoint(&mut self, char_offset: usize, utf16_offset: usize) { + if self.checkpoints.last().map(|(c, _)| *c) != Some(char_offset) { + self.checkpoints.push((char_offset, utf16_offset)); + } + } + + /// Get the last checkpoint. + pub fn last(&self) -> (usize, usize) { + self.checkpoints.last().copied().unwrap_or((0, 0)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_node_id_generator() { + let mut generator = NodeIdGenerator::default(); + generator.prefix = Some("p-0".to_smolstr()); + + assert_eq!(generator.next_node().as_str(), "p-0-n0"); + assert_eq!(generator.next_node().as_str(), "p-0-n1"); + assert_eq!(generator.next_syn().as_str(), "s0"); + assert_eq!(generator.next_syn().as_str(), "s1"); + } + + #[test] + fn test_node_id_generator_auto_increment() { + let mut generator = NodeIdGenerator::default(); + generator.auto_increment_base = Some(0); + generator.prefix = Some("p-0".to_smolstr()); + + assert_eq!(generator.next_node().as_str(), "p-0-n0"); + generator.next_paragraph(); + assert_eq!(generator.prefix, Some("p-1".to_smolstr())); + assert_eq!(generator.next_node().as_str(), "p-1-n0"); + } + + #[test] + fn test_paragraph_tracker() { + let mut tracker = ParagraphTracker::default(); + + tracker.start_paragraph(0, 0); + let ranges = tracker.end_paragraph(10, 10); + assert_eq!(ranges, Some((0..10, 0..10))); + + tracker.list_depth = 1; + assert!(tracker.in_list()); + assert!(!tracker.should_track_boundaries()); + } + + #[test] + fn test_code_block_context() { + let mut ctx = CodeBlockContext::default(); + assert!(!ctx.is_active()); + + ctx.buffer = Some((Some("rust".to_smolstr()), String::new())); + assert!(ctx.is_active()); + + ctx.clear(); + assert!(!ctx.is_active()); + } +} diff --git a/crates/weaver-editor-core/src/writer/syntax.rs b/crates/weaver-editor-core/src/writer/syntax.rs new file mode 100644 index 0000000..f1de835 --- /dev/null +++ b/crates/weaver-editor-core/src/writer/syntax.rs @@ -0,0 +1,196 @@ +//! Syntax span emission methods for EditorWriter. + +use core::fmt; +use std::ops::Range; + +use markdown_weaver::Event; +use markdown_weaver_escape::{StrWrite, escape_html}; + +use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; +use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; + +use super::EditorWriter; + +impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +where + I: Iterator, Range)>, + E: EmbedContentProvider, + R: ImageResolver, + W: WikilinkValidator, +{ + /// Emit syntax span for a given range and record offset mapping. + pub(crate) fn emit_syntax(&mut self, range: Range) -> Result<(), fmt::Error> { + if range.start < range.end { + let syntax = &self.source[range.clone()]; + if !syntax.is_empty() { + let char_start = self.last_char_offset; + let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; + + tracing::trace!( + target: "weaver::writer", + byte_range = ?range, + char_range = ?(char_start..char_end), + syntax = %syntax.escape_debug(), + "emit_syntax" + ); + + // Whitespace-only content (trailing spaces, newlines) should be emitted + // as plain text, not wrapped in a hideable syntax span + let is_whitespace_only = syntax.trim().is_empty(); + + if is_whitespace_only { + // Emit as plain text with tracking span (not hideable) + let created_node = if self.current_node.id.is_none() { + let node_id = self.gen_node_id(); + write!(&mut self.writer, "", node_id)?; + self.begin_node(node_id); + true + } else { + false + }; + + escape_html(&mut self.writer, syntax)?; + + // Record offset mapping BEFORE end_node (which clears current_node.id) + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + + if created_node { + self.write("")?; + self.end_node(); + } + } else { + // Real syntax - wrap in hideable span + let syntax_type = classify_syntax(syntax); + let class = match syntax_type { + SyntaxType::Inline => "md-syntax-inline", + SyntaxType::Block => "md-syntax-block", + }; + + // Generate unique ID for this syntax span + let syn_id = self.gen_syn_id(); + + // If we're outside any node, create a wrapper span for tracking + let created_node = if self.current_node.id.is_none() { + let node_id = self.gen_node_id(); + write!( + &mut self.writer, + "", + node_id, class, syn_id, char_start, char_end + )?; + self.begin_node(node_id); + true + } else { + write!( + &mut self.writer, + "", + class, syn_id, char_start, char_end + )?; + false + }; + + escape_html(&mut self.writer, syntax)?; + self.write("")?; + + // Record syntax span info for visibility toggling + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type, + formatted_range: None, + }); + + // Record offset mapping for this syntax + self.record_mapping(range.clone(), char_start..char_end); + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + + // Close wrapper if we created one + if created_node { + self.write("")?; + self.end_node(); + } + } + } + } + Ok(()) + } + + /// Emit syntax span inside current node with full offset tracking. + /// + /// Use this for syntax markers that appear inside block elements (headings, lists, + /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates + /// wrapper nodes, this assumes we're already inside a tracked node. + /// + /// - Writes `{syntax}` + /// - Records offset mapping (for cursor positioning) + /// - Updates both `last_char_offset` and `last_byte_offset` + pub(crate) fn emit_inner_syntax( + &mut self, + syntax: &str, + byte_start: usize, + syntax_type: SyntaxType, + ) -> Result<(), fmt::Error> { + if syntax.is_empty() { + return Ok(()); + } + + let char_start = self.last_char_offset; + let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; + let byte_end = byte_start + syntax.len(); + + let class_str = match syntax_type { + SyntaxType::Inline => "md-syntax-inline", + SyntaxType::Block => "md-syntax-block", + }; + + // Generate unique ID for this syntax span + let syn_id = self.gen_syn_id(); + + write!( + &mut self.writer, + "", + class_str, syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, syntax)?; + self.write("")?; + + // Record syntax span info for visibility toggling + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type, + formatted_range: None, + }); + + // Record offset mapping for cursor positioning + self.record_mapping(byte_start..byte_end, char_start..char_end); + + self.last_char_offset = char_end; + self.last_byte_offset = byte_end; + + Ok(()) + } + + /// Emit any gap between last position and next offset. + pub(crate) fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> { + // Skip gap emission if we're inside a table being rendered as markdown + if self.table.start_offset.is_some() && self.table.render_as_markdown { + return Ok(()); + } + + // Skip gap emission if we're buffering code block content + // The code block handler manages its own syntax emission + if self.code_block.is_active() { + return Ok(()); + } + + if next_offset > self.last_byte_offset { + self.emit_syntax(self.last_byte_offset..next_offset)?; + } + Ok(()) + } +} diff --git a/crates/weaver-editor-core/src/writer/tags.rs b/crates/weaver-editor-core/src/writer/tags.rs new file mode 100644 index 0000000..d20c178 --- /dev/null +++ b/crates/weaver-editor-core/src/writer/tags.rs @@ -0,0 +1,1629 @@ +//! Tag handling for EditorWriter - start_tag and end_tag methods. + +use core::fmt; +use std::ops::Range; + +use markdown_weaver::{Alignment, BlockQuoteKind, CodeBlockKind, EmbedType, Event, LinkType, Tag}; +use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; + +use crate::offset_map::OffsetMapping; +use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator}; +use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax}; + +use super::{EditorWriter, TableState}; + +impl<'a, I, E, R, W> EditorWriter<'a, I, E, R, W> +where + I: Iterator, Range)>, + E: EmbedContentProvider, + R: ImageResolver, + W: WikilinkValidator, +{ + /// Detect text direction by scanning source from a byte offset. + /// Looks for the first strong directional character. + /// Returns Some("rtl") for RTL scripts, Some("ltr") for LTR, None if no strong char found. + fn detect_paragraph_direction(&self, start_byte: usize) -> Option<&'static str> { + if start_byte >= self.source.len() { + return None; + } + + // Scan from start_byte through the source looking for first strong directional char + let text = &self.source[start_byte..]; + weaver_renderer::utils::detect_text_direction(text) + } + + pub(crate) fn start_tag( + &mut self, + tag: Tag<'_>, + range: Range, + ) -> Result<(), fmt::Error> { + // Check if this is a block-level tag that should have syntax inside + let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_)); + + // For inline tags, emit syntax before tag + if !is_block_tag && range.start < range.end { + let raw_text = &self.source[range.clone()]; + let opening_syntax = match &tag { + Tag::Strong => { + if raw_text.starts_with("**") { + Some("**") + } else if raw_text.starts_with("__") { + Some("__") + } else { + None + } + } + Tag::Emphasis => { + if raw_text.starts_with("*") { + Some("*") + } else if raw_text.starts_with("_") { + Some("_") + } else { + None + } + } + Tag::Strikethrough => { + if raw_text.starts_with("~~") { + Some("~~") + } else { + None + } + } + Tag::Link { link_type, .. } => { + if matches!(link_type, LinkType::WikiLink { .. }) { + if raw_text.starts_with("[[") { + Some("[[") + } else { + None + } + } else if raw_text.starts_with('[') { + Some("[") + } else { + None + } + } + // Note: Tag::Image and Tag::Embed handle their own syntax spans + // in their respective handlers, so don't emit here + _ => None, + }; + + if let Some(syntax) = opening_syntax { + let syntax_type = classify_syntax(syntax); + let class = match syntax_type { + SyntaxType::Inline => "md-syntax-inline", + SyntaxType::Block => "md-syntax-block", + }; + + let char_start = self.last_char_offset; + let syntax_char_len = syntax.chars().count(); + let char_end = char_start + syntax_char_len; + let syntax_byte_len = syntax.len(); + + // Generate unique ID for this syntax span + let syn_id = self.gen_syn_id(); + + write!( + &mut self.writer, + "", + class, syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, syntax)?; + self.write("")?; + + // Record syntax span info for visibility toggling + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id: syn_id.clone(), + char_range: char_start..char_end, + syntax_type, + formatted_range: None, // Will be updated when closing tag is emitted + }); + + // Record offset mapping for cursor positioning + // This is critical - without it, current_node_char_offset is wrong + // and all subsequent cursor positions are shifted + let byte_start = range.start; + let byte_end = range.start + syntax_byte_len; + self.record_mapping(byte_start..byte_end, char_start..char_end); + + // For paired inline syntax, track opening span for formatted_range + if matches!( + tag, + Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } + ) { + self.current_para + .pending_inline_formats + .push((syn_id, char_start)); + } + + // Update tracking - we've consumed this opening syntax + self.last_char_offset = char_end; + self.last_byte_offset = range.start + syntax_byte_len; + } + } + + // Emit the opening tag + match tag { + // HTML blocks get their own paragraph to try and corral them better + Tag::HtmlBlock => { + // Record paragraph start for boundary tracking + // Skip if inside a list or footnote def - they own their paragraph boundary + if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def { + self.paragraphs.current_start = + Some((self.last_byte_offset, self.last_char_offset)); + } + let node_id = self.gen_node_id(); + + if self.end_newline { + write!( + &mut self.writer, + r#"

"#, + node_id + )?; + } else { + write!( + &mut self.writer, + r#"

"#, + node_id + )?; + } + self.begin_node(node_id.clone()); + + // Map the start position of the paragraph (before any content) + // This allows cursor to be placed at the very beginning + let para_start_char = self.last_char_offset; + let mapping = OffsetMapping { + byte_range: range.start..range.start, + char_range: para_start_char..para_start_char, + node_id, + char_offset_in_node: 0, + child_index: Some(0), // position before first child + utf16_len: 0, + }; + self.current_para.offset_maps.push(mapping); + + Ok(()) + } + Tag::Paragraph(_) => { + // Handle wrapper before block + self.emit_wrapper_start()?; + + // Record paragraph start for boundary tracking + // Skip if inside a list or footnote def - they own their paragraph boundary + if self.paragraphs.list_depth == 0 && !self.paragraphs.in_footnote_def { + self.paragraphs.current_start = + Some((self.last_byte_offset, self.last_char_offset)); + } + + let node_id = self.gen_node_id(); + + // Detect text direction for this paragraph + let dir = self.detect_paragraph_direction(self.last_byte_offset); + + if self.end_newline { + if let Some(dir_value) = dir { + write!( + &mut self.writer, + "

", + node_id, dir_value + )?; + } else { + write!(&mut self.writer, "

", node_id)?; + } + } else { + if let Some(dir_value) = dir { + write!( + &mut self.writer, + "

", + node_id, dir_value + )?; + } else { + write!(&mut self.writer, "

", node_id)?; + } + } + self.begin_node(node_id.clone()); + + // Map the start position of the paragraph (before any content) + // This allows cursor to be placed at the very beginning + let para_start_char = self.last_char_offset; + let mapping = OffsetMapping { + byte_range: range.start..range.start, + char_range: para_start_char..para_start_char, + node_id, + char_offset_in_node: 0, + child_index: Some(0), // position before first child + utf16_len: 0, + }; + self.current_para.offset_maps.push(mapping); + + // Emit > syntax if we're inside a blockquote + if let Some(bq_range) = self.pending_blockquote_range.take() { + if bq_range.start < bq_range.end { + let raw_text = &self.source[bq_range.clone()]; + if let Some(gt_pos) = raw_text.find('>') { + // Extract > [!NOTE] or just > + let after_gt = &raw_text[gt_pos + 1..]; + let syntax_end = if after_gt.trim_start().starts_with("[!") { + // Find the closing ] + if let Some(close_bracket) = after_gt.find(']') { + gt_pos + 1 + close_bracket + 1 + } else { + gt_pos + 1 + } + } else { + // Just > and maybe a space + (gt_pos + 1).min(raw_text.len()) + }; + + let syntax = &raw_text[gt_pos..syntax_end]; + let syntax_byte_start = bq_range.start + gt_pos; + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; + } + } + } + Ok(()) + } + Tag::Heading { + level, + id, + classes, + attrs, + } => { + // Emit wrapper if pending (but don't close on heading end - wraps following block too) + self.emit_wrapper_start()?; + + // Record paragraph start for boundary tracking + // Treat headings as paragraph-level blocks + self.paragraphs.current_start = + Some((self.last_byte_offset, self.last_char_offset)); + + if !self.end_newline { + self.write_newline()?; + } + + // Generate node ID for offset tracking + let node_id = self.gen_node_id(); + + // Detect text direction for this heading + let dir = self.detect_paragraph_direction(self.last_byte_offset); + + self.write("<")?; + write!(&mut self.writer, "{}", level)?; + + // Add our tracking ID as data attribute (preserve user's id if present) + self.write(" data-node-id=\"")?; + self.write(&node_id)?; + self.write("\"")?; + + if let Some(id) = id { + self.write(" id=\"")?; + escape_html(&mut self.writer, &id)?; + self.write("\"")?; + } + if !classes.is_empty() { + self.write(" class=\"")?; + for (i, class) in classes.iter().enumerate() { + if i > 0 { + self.write(" ")?; + } + escape_html(&mut self.writer, class)?; + } + self.write("\"")?; + } + + // Add dir attribute if text direction was detected + if let Some(dir_value) = dir { + self.write(" dir=\"")?; + self.write(dir_value)?; + self.write("\"")?; + } + + for (attr, value) in attrs { + self.write(" ")?; + escape_html(&mut self.writer, &attr)?; + if let Some(val) = value { + self.write("=\"")?; + escape_html(&mut self.writer, &val)?; + self.write("\"")?; + } else { + self.write("=\"\"")?; + } + } + self.write(">")?; + + // Begin node tracking for offset mapping + self.begin_node(node_id.clone()); + + // Map the start position of the heading (before any content) + // This allows cursor to be placed at the very beginning + let heading_start_char = self.last_char_offset; + let mapping = OffsetMapping { + byte_range: range.start..range.start, + char_range: heading_start_char..heading_start_char, + node_id: node_id.clone(), + char_offset_in_node: 0, + child_index: Some(0), // position before first child + utf16_len: 0, + }; + self.current_para.offset_maps.push(mapping); + + // Emit # syntax inside the heading tag + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + let count = level as usize; + let pattern = "#".repeat(count); + + // Find where the # actually starts (might have leading whitespace) + if let Some(hash_pos) = raw_text.find(&pattern) { + // Extract "# " or "## " etc + let syntax_end = (hash_pos + count + 1).min(raw_text.len()); + let syntax = &raw_text[hash_pos..syntax_end]; + let syntax_byte_start = range.start + hash_pos; + + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; + } + } + Ok(()) + } + Tag::Table(alignments) => { + if self.table.render_as_markdown { + // Store start offset and skip HTML rendering + self.table.start_offset = Some(range.start); + self.in_non_writing_block = true; // Suppress content output + Ok(()) + } else { + self.emit_wrapper_start()?; + self.table.alignments = alignments; + self.write("") + } + } + Tag::TableHead => { + if self.table.render_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.table.state = TableState::Head; + self.table.cell_index = 0; + self.write("") + } + } + Tag::TableRow => { + if self.table.render_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.table.cell_index = 0; + self.write("") + } + } + Tag::TableCell => { + if self.table.render_as_markdown { + Ok(()) // Skip HTML rendering + } else { + match self.table.state { + TableState::Head => self.write(" self.write(" self.write(" style=\"text-align: left\">"), + Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), + Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), + _ => self.write(">"), + } + } + } + Tag::BlockQuote(kind) => { + self.emit_wrapper_start()?; + + let class_str = match kind { + None => "", + Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", + Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"", + Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"", + Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"", + Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"", + }; + if self.end_newline { + write!(&mut self.writer, "", class_str)?; + } else { + write!(&mut self.writer, "", class_str)?; + } + + // Store range for emitting > inside the next paragraph + self.pending_blockquote_range = Some(range); + Ok(()) + } + Tag::CodeBlock(info) => { + self.emit_wrapper_start()?; + + // Track code block as paragraph-level block + self.paragraphs.current_start = + Some((self.last_byte_offset, self.last_char_offset)); + + if !self.end_newline { + self.write_newline()?; + } + + // Generate node ID for code block + let node_id = self.gen_node_id(); + + match info { + CodeBlockKind::Fenced(info) => { + // Emit opening ```language and track both char and byte offsets + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + if let Some(fence_pos) = raw_text.find("```") { + let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); + let syntax = &raw_text[fence_pos..fence_end]; + let syntax_char_len = syntax.chars().count() + 1; // +1 for newline + let syntax_byte_len = syntax.len() + 1; // +1 for newline + + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + syntax_char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, syntax)?; + self.write("")?; + + // Track opening span index for formatted_range update later + self.code_block.opening_span_idx = + Some(self.current_para.syntax_spans.len()); + self.code_block.block_start = Some(char_start); + + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range: None, // Will be set in TagEnd::CodeBlock + }); + + self.last_char_offset += syntax_char_len; + self.last_byte_offset = range.start + fence_pos + syntax_byte_len; + } + } + + let lang = info.split(' ').next().unwrap(); + let lang_opt = if lang.is_empty() { + None + } else { + Some(smol_str::SmolStr::new(lang)) + }; + // Start buffering + self.code_block.buffer = Some((lang_opt, String::new())); + + // Begin node tracking for offset mapping + self.begin_node(node_id); + Ok(()) + } + CodeBlockKind::Indented => { + // Ignore indented code blocks (as per executive decision) + self.code_block.buffer = Some((None, String::new())); + + // Begin node tracking for offset mapping + self.begin_node(node_id); + Ok(()) + } + } + } + Tag::List(Some(1)) => { + self.emit_wrapper_start()?; + // Track list as paragraph-level block + self.paragraphs.current_start = + Some((self.last_byte_offset, self.last_char_offset)); + self.paragraphs.list_depth += 1; + if self.end_newline { + self.write("
    ") + } else { + self.write("
      ") + } + } + Tag::List(Some(start)) => { + self.emit_wrapper_start()?; + // Track list as paragraph-level block + self.paragraphs.current_start = + Some((self.last_byte_offset, self.last_char_offset)); + self.paragraphs.list_depth += 1; + if self.end_newline { + self.write("
        ") + } + Tag::List(None) => { + self.emit_wrapper_start()?; + // Track list as paragraph-level block + self.paragraphs.current_start = + Some((self.last_byte_offset, self.last_char_offset)); + self.paragraphs.list_depth += 1; + if self.end_newline { + self.write("
") + } + } + TagEnd::TableHead => { + if self.table.render_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.write("")?; + self.table.state = TableState::Body; + Ok(()) + } + } + TagEnd::TableRow => { + if self.table.render_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.write("") + } + } + TagEnd::TableCell => { + if self.table.render_as_markdown { + Ok(()) // Skip HTML rendering + } else { + match self.table.state { + TableState::Head => self.write("")?, + TableState::Body => self.write("")?, + } + self.table.cell_index += 1; + Ok(()) + } + } + TagEnd::BlockQuote(_) => { + // If pending_blockquote_range is still set, the blockquote was empty + // (no paragraph inside). Emit the > as its own minimal paragraph. + let mut para_boundary = None; + if let Some(bq_range) = self.pending_blockquote_range.take() { + if bq_range.start < bq_range.end { + let raw_text = &self.source[bq_range.clone()]; + if let Some(gt_pos) = raw_text.find('>') { + let para_byte_start = bq_range.start + gt_pos; + let para_char_start = self.last_char_offset; + + // Create a minimal paragraph for the empty blockquote + let node_id = self.gen_node_id(); + write!(&mut self.writer, "

")?; + self.end_node(); + + // Capture paragraph boundary for later finalization + let byte_range = para_byte_start..bq_range.end; + let char_range = para_char_start..self.last_char_offset; + para_boundary = Some((byte_range, char_range)); + } + } + } + self.write("")?; + self.close_wrapper()?; + + // Now finalize paragraph if we had one + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); + } + Ok(()) + } + TagEnd::CodeBlock => { + use std::sync::LazyLock; + use syntect::parsing::SyntaxSet; + static SYNTAX_SET: LazyLock = + LazyLock::new(|| SyntaxSet::load_defaults_newlines()); + + if let Some((lang, buffer)) = self.code_block.buffer.take() { + // Create offset mapping for code block content if we tracked ranges + if let (Some(code_byte_range), Some(code_char_range)) = ( + self.code_block.byte_range.take(), + self.code_block.char_range.take(), + ) { + // Record mapping before writing HTML + // (current_node.id should be set by start_tag for CodeBlock) + self.record_mapping(code_byte_range, code_char_range); + } + + // Get node_id for data-node-id attribute (needed for cursor positioning) + let node_id = self.current_node.id.clone(); + + if let Some(ref lang_str) = lang { + // Use a temporary String buffer for syntect + let mut temp_output = String::new(); + match weaver_renderer::code_pretty::highlight( + &SYNTAX_SET, + Some(lang_str), + &buffer, + &mut temp_output, + ) { + Ok(_) => { + // Inject data-node-id into the
 tag for cursor positioning
+                                if let Some(ref nid) = node_id {
+                                    let injected = temp_output.replacen(
+                                        "
",
+                                        &format!("
", nid),
+                                        1,
+                                    );
+                                    self.write(&injected)?;
+                                } else {
+                                    self.write(&temp_output)?;
+                                }
+                            }
+                            Err(_) => {
+                                // Fallback to plain code block
+                                if let Some(ref nid) = node_id {
+                                    write!(
+                                        &mut self.writer,
+                                        "
")?;
+                                escape_html_body_text(&mut self.writer, &buffer)?;
+                                self.write("
")?; + } + } + } else { + if let Some(ref nid) = node_id { + write!(&mut self.writer, "
", nid)?;
+                        } else {
+                            self.write("
")?;
+                        }
+                        escape_html_body_text(&mut self.writer, &buffer)?;
+                        self.write("
")?; + } + + // End node tracking + self.end_node(); + } else { + self.write("
")?; + } + + // Emit closing ``` (emit_gap_before is skipped while buffering) + // Track the opening span index and char start before we potentially clear them + let opening_span_idx = self.code_block.opening_span_idx.take(); + let code_block_start = self.code_block.block_start.take(); + + if range.start < range.end { + let raw_text = &self.source[range.clone()]; + if let Some(fence_line) = raw_text.lines().last() { + if fence_line.trim().starts_with("```") { + let fence = fence_line.trim(); + let fence_char_len = fence.chars().count(); + + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + fence_char_len; + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, fence)?; + self.write("")?; + + self.last_char_offset += fence_char_len; + self.last_byte_offset += fence.len(); + + // Compute formatted_range for entire code block (opening fence to closing fence) + let formatted_range = + code_block_start.map(|start| start..self.last_char_offset); + + // Update opening fence span with formatted_range + if let (Some(idx), Some(fr)) = + (opening_span_idx, formatted_range.as_ref()) + { + if let Some(span) = self.current_para.syntax_spans.get_mut(idx) { + span.formatted_range = Some(fr.clone()); + } + } + + // Push closing fence span with formatted_range + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range, + }); + } + } + } + + // Finalize code block paragraph + if let Some((byte_start, char_start)) = self.paragraphs.current_start.take() { + let byte_range = byte_start..self.last_byte_offset; + let char_range = char_start..self.last_char_offset; + self.finalize_paragraph(byte_range, char_range); + } + + Ok(()) + } + TagEnd::List(true) => { + self.paragraphs.list_depth = self.paragraphs.list_depth.saturating_sub(1); + // Capture paragraph boundary BEFORE writing closing HTML + let para_boundary = + self.paragraphs + .current_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }); + + self.write("")?; + self.close_wrapper()?; + + // Finalize paragraph after closing HTML + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); + } + Ok(()) + } + TagEnd::List(false) => { + self.paragraphs.list_depth = self.paragraphs.list_depth.saturating_sub(1); + // Capture paragraph boundary BEFORE writing closing HTML + let para_boundary = + self.paragraphs + .current_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }); + + self.write("")?; + self.close_wrapper()?; + + // Finalize paragraph after closing HTML + if let Some((byte_range, char_range)) = para_boundary { + self.finalize_paragraph(byte_range, char_range); + } + Ok(()) + } + TagEnd::Item => { + self.end_node(); + self.write("") + } + TagEnd::DefinitionList => { + self.write("")?; + self.close_wrapper() + } + TagEnd::DefinitionListTitle => { + self.end_node(); + self.write("") + } + TagEnd::DefinitionListDefinition => { + self.end_node(); + self.write("") + } + TagEnd::Emphasis => { + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag + self.write("
")?; + self.emit_gap_before(range.end)?; + self.current_para + .finalize_paired_format(self.last_char_offset); + Ok(()) + } + TagEnd::Superscript => self.write(""), + TagEnd::Subscript => self.write(""), + TagEnd::Strong => { + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag + self.write("
")?; + self.emit_gap_before(range.end)?; + self.current_para + .finalize_paired_format(self.last_char_offset); + Ok(()) + } + TagEnd::Strikethrough => { + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag + self.write("
")?; + self.emit_gap_before(range.end)?; + self.current_para + .finalize_paired_format(self.last_char_offset); + Ok(()) + } + TagEnd::Link => { + self.write("
")?; + // Check if this is a wiki link (ends with ]]) vs regular link (ends with )) + let raw_text = &self.source[range.clone()]; + if raw_text.ends_with("]]") { + // WikiLink: emit ]] as closing syntax + let syn_id = self.gen_syn_id(); + let char_start = self.last_char_offset; + let char_end = char_start + 2; + + write!( + &mut self.writer, + "]]", + syn_id, char_start, char_end + )?; + + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Inline, + formatted_range: None, // Will be set by finalize + }); + + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + } else { + self.emit_gap_before(range.end)?; + } + self.current_para + .finalize_paired_format(self.last_char_offset); + Ok(()) + } + TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event + TagEnd::Embed => Ok(()), + TagEnd::WeaverBlock(_) => { + self.in_non_writing_block = false; + + // Emit the { content } as a hideable syntax span + if let Some(char_start) = self.weaver_block.char_start.take() { + // Build the full syntax text: { buffered_content } + let syntax_text = format!("{{{}}}", self.weaver_block.buffer); + let syntax_char_len = syntax_text.chars().count(); + let char_end = char_start + syntax_char_len; + + let syn_id = self.gen_syn_id(); + + write!( + &mut self.writer, + "", + syn_id, char_start, char_end + )?; + escape_html(&mut self.writer, &syntax_text)?; + self.write("")?; + + // Track the syntax span + self.current_para.syntax_spans.push(SyntaxSpanInfo { + syn_id, + char_range: char_start..char_end, + syntax_type: SyntaxType::Block, + formatted_range: None, + }); + + // Record offset mapping for the syntax span + self.record_mapping(range.clone(), char_start..char_end); + + // Update tracking + self.last_char_offset = char_end; + self.last_byte_offset = range.end; + } + + // Parse the buffered text for attrs and store for next block + if !self.weaver_block.buffer.is_empty() { + let parsed = Self::parse_weaver_attrs(&self.weaver_block.buffer); + self.weaver_block.buffer.clear(); + // Merge with any existing pending attrs or set new + if let Some(ref mut existing) = self.weaver_block.pending_attrs { + existing.classes.extend(parsed.classes); + existing.attrs.extend(parsed.attrs); + } else { + self.weaver_block.pending_attrs = Some(parsed); + } + } + + Ok(()) + } + TagEnd::FootnoteDefinition => { + // End node tracking (inner paragraphs may have already cleared it) + self.end_node(); + self.write("")?; + + // Clear footnote tracking + self.footnotes.current_def.take(); + self.paragraphs.in_footnote_def = false; + + // Finalize paragraph boundary for incremental rendering + if let Some((byte_start, char_start)) = self.paragraphs.current_start.take() { + let byte_range = byte_start..self.last_byte_offset; + let char_range = char_start..self.last_char_offset; + self.finalize_paragraph(byte_range, char_range); + } + + Ok(()) + } + TagEnd::MetadataBlock(_) => { + self.in_non_writing_block = false; + Ok(()) + } + }; + + result?; + + // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough) + // is handled INSIDE their respective match arms above, AFTER writing the closing HTML. + // This ensures the closing syntax span appears OUTSIDE the formatted element. + // Other End events have their closing syntax emitted by emit_gap_before() in the main loop. + + Ok(()) + } + + /// Emit wrapper start if pending attributes require it. + fn emit_wrapper_start(&mut self) -> Result<(), fmt::Error> { + if let Some(attrs) = self.weaver_block.pending_attrs.take() { + let wrapper = if attrs.classes.iter().any(|c| c.as_ref() == "aside") { + super::WrapperElement::Aside + } else { + super::WrapperElement::Div + }; + + match wrapper { + super::WrapperElement::Aside => { + self.write(" { + self.write(" = attrs + .classes + .iter() + .filter(|c| c.as_ref() != "aside") + .collect(); + if !classes.is_empty() { + self.write(" class=\"")?; + for (i, class) in classes.iter().enumerate() { + if i > 0 { + self.write(" ")?; + } + escape_html(&mut self.writer, class)?; + } + self.write("\"")?; + } + + // Emit other attributes + for (attr, value) in &attrs.attrs { + self.write(" ")?; + escape_html(&mut self.writer, attr)?; + self.write("=\"")?; + escape_html(&mut self.writer, value)?; + self.write("\"")?; + } + + self.write(">")?; + self.weaver_block.active_wrapper = Some(wrapper); + } + Ok(()) + } + + /// Close wrapper if one is active. + fn close_wrapper(&mut self) -> Result<(), fmt::Error> { + if let Some(wrapper) = self.weaver_block.active_wrapper.take() { + match wrapper { + super::WrapperElement::Aside => self.write("")?, + super::WrapperElement::Div => self.write("")?, + } + } + Ok(()) + } + + /// Parse weaver block text into attributes. + fn parse_weaver_attrs(text: &str) -> markdown_weaver::WeaverAttributes<'static> { + let mut classes = Vec::new(); + let mut attrs = Vec::new(); + + for part in text.split_whitespace() { + if part.starts_with('.') { + // Class: .classname + classes.push(markdown_weaver::CowStr::from(part[1..].to_string())); + } else if part.starts_with('#') { + // ID: #idname -> id="idname" + attrs.push(( + markdown_weaver::CowStr::from("id".to_string()), + markdown_weaver::CowStr::from(part[1..].to_string()), + )); + } else if let Some((key, value)) = part.split_once('=') { + // Key=value attribute + let value = value.trim_matches('"').trim_matches('\''); + attrs.push(( + markdown_weaver::CowStr::from(key.to_string()), + markdown_weaver::CowStr::from(value.to_string()), + )); + } + } + + markdown_weaver::WeaverAttributes { classes, attrs } + } +}