diff --git a/Cargo.lock b/Cargo.lock index 5c90018..e3941f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -980,9 +980,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.49" +version = "1.2.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" dependencies = [ "find-msvc-tools", "jobserver", @@ -2805,7 +2805,7 @@ dependencies = [ [[package]] name = "dioxus-primitives" version = "0.0.1" -source = "git+https://github.com/DioxusLabs/components#3564270718866d2e886f879973afc77d7c3a1689" +source = "git+https://github.com/DioxusLabs/components#545aa7f55205b488f6403f92133fffb6c66838de" dependencies = [ "dioxus 0.7.2", "dioxus-sdk-time", @@ -5568,8 +5568,8 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jacquard" -version = "0.9.4" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.5" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "bytes", "getrandom 0.2.16", @@ -5585,7 +5585,6 @@ dependencies = [ "regex", "regex-lite", "reqwest", - "ring", "serde", "serde_html_form", "serde_json", @@ -5600,8 +5599,8 @@ dependencies = [ [[package]] name = "jacquard-api" -version = "0.9.2" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.5" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "bon", "bytes", @@ -5619,8 +5618,8 @@ dependencies = [ [[package]] name = "jacquard-axum" -version = "0.9.2" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.6" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "axum", "bytes", @@ -5641,8 +5640,8 @@ dependencies = [ [[package]] name = "jacquard-common" -version = "0.9.2" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.5" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "base64 0.22.1", "bon", @@ -5669,7 +5668,6 @@ dependencies = [ "regex", "regex-lite", "reqwest", - "ring", "serde", "serde_bytes", "serde_html_form", @@ -5689,8 +5687,8 @@ dependencies = [ [[package]] name = "jacquard-derive" -version = "0.9.4" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.5" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "heck 0.5.0", "jacquard-lexicon", @@ -5701,8 +5699,8 @@ dependencies = [ [[package]] name = "jacquard-identity" -version = "0.9.2" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.5" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "bon", "bytes", @@ -5712,11 +5710,10 @@ dependencies = [ "jacquard-common", "jacquard-lexicon", "miette 7.6.0", - "mini-moka 0.10.99", + "mini-moka-wasm", "n0-future 0.1.3", "percent-encoding", "reqwest", - "ring", "serde", "serde_html_form", "serde_json", @@ -5730,8 +5727,8 @@ dependencies = [ [[package]] name = "jacquard-lexicon" -version = "0.9.2" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.5" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "cid", "dashmap 6.1.0", @@ -5756,8 +5753,8 @@ dependencies = [ [[package]] name = "jacquard-oauth" -version = "0.9.2" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.6" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "base64 0.22.1", "bytes", @@ -5772,7 +5769,6 @@ dependencies = [ "miette 7.6.0", "p256", "rand 0.8.5", - "ring", "rouille", "serde", "serde_html_form", @@ -5789,8 +5785,8 @@ dependencies = [ [[package]] name = "jacquard-repo" -version = "0.9.4" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.9.5" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "bytes", "cid", @@ -6482,7 +6478,7 @@ dependencies = [ [[package]] name = "markdown-weaver" version = "0.13.0" -source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" +source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#5f4257b7ee3175f73974b43c3269b2130a4479ca" dependencies = [ "bitflags 2.10.0", "getopts", @@ -6495,7 +6491,7 @@ dependencies = [ [[package]] name = "markdown-weaver-escape" version = "0.11.0" -source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" +source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#5f4257b7ee3175f73974b43c3269b2130a4479ca" [[package]] name = "markup5ever" @@ -6747,8 +6743,8 @@ dependencies = [ [[package]] name = "mini-moka" -version = "0.10.99" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" +version = "0.11.0" +source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d" dependencies = [ "crossbeam-channel", "crossbeam-utils", @@ -6760,9 +6756,9 @@ dependencies = [ ] [[package]] -name = "mini-moka" -version = "0.11.0" -source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d" +name = "mini-moka-wasm" +version = "0.10.99" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" dependencies = [ "crossbeam-channel", "crossbeam-utils", @@ -8030,9 +8026,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" [[package]] name = "portmapper" @@ -11679,7 +11675,7 @@ dependencies = [ "markdown-weaver", "markdown-weaver-escape", "mime-sniffer", - "mini-moka 0.11.0", + "mini-moka", "n0-future 0.1.3", "postcard", "regex", @@ -11801,7 +11797,7 @@ dependencies = [ "k256", "loro", "miette 7.6.0", - "mini-moka 0.11.0", + "mini-moka", "n0-future 0.1.3", "rand 0.8.5", "regex", diff --git a/crates/weaver-app/assets/styling/entry.css b/crates/weaver-app/assets/styling/entry.css index 65ea6c5..82600e1 100644 --- a/crates/weaver-app/assets/styling/entry.css +++ b/crates/weaver-app/assets/styling/entry.css @@ -1,6 +1,6 @@ /* Entry page layout - centered content with sidenote margin */ .entry-page { - --content-width: 65ch; + --content-width: 95ch; --sidenote-width: 14rem; --sidenote-gap: 1.5rem; @@ -39,14 +39,17 @@ color: var(--color-primary); } -.entry-header .nav-button-prev { - flex-shrink: 1; +.entry-header .nav-button-prev, +.entry-header .nav-placeholder:first-child { + flex: 1; min-width: 0; } -.entry-header .nav-button-next { - flex-shrink: 1; +.entry-header .nav-button-next, +.entry-header .nav-placeholder:last-child { + flex: 1; min-width: 0; + justify-content: flex-end; } .entry-header .nav-arrow { @@ -60,10 +63,10 @@ text-overflow: ellipsis; } -/* Metadata takes center, flexes to fill */ +/* Metadata anchored to content width */ .entry-header .entry-metadata { - flex: 1; - max-width: var(--content-width); + width: min(var(--content-width), 100%); + flex-shrink: 1; margin: 0; padding: 0; border: none; @@ -80,13 +83,11 @@ .entry-content-main { width: var(--content-width); max-width: 100%; + border-top: 1px solid var(--color-border); position: relative; } -/* When sidenotes exist, shift content left to make room */ -.entry-content-main:has(.sidenote) { - margin-right: calc(var(--sidenote-width) + var(--sidenote-gap)); -} +/* Sidenote layout handled by css.rs body padding */ /* Footer navigation */ .entry-footer-nav { @@ -295,26 +296,14 @@ } } -/* Responsive: when sidenotes need to squeeze */ -@media (max-width: 1000px) { - .entry-content-main:has(.sidenote) { - margin-right: calc(var(--sidenote-width) + 0.5rem); - margin-left: -3rem; - } -} - -/* Responsive: narrower - compress left margin more */ +/* Responsive: narrower */ @media (max-width: 900px) { .entry-header .nav-title { max-width: 8rem; } - - .entry-content-main:has(.sidenote) { - margin-left: -5rem; - } } -/* Responsive: mobile - sidenotes go inline */ +/* Responsive: mobile */ @media (max-width: 768px) { .entry-header { flex-direction: column; @@ -322,11 +311,6 @@ gap: 0.5rem; } - .entry-content-main:has(.sidenote) { - margin-left: 0; - margin-right: 0; - } - .entry-footer-nav { flex-direction: column; gap: 1rem; diff --git a/crates/weaver-app/src/components/editor/component.rs b/crates/weaver-app/src/components/editor/component.rs index e1bc286..078aa3c 100644 --- a/crates/weaver-app/src/components/editor/component.rs +++ b/crates/weaver-app/src/components/editor/component.rs @@ -26,7 +26,9 @@ use super::sync::{SyncStatus, load_and_merge_document}; use super::toolbar::EditorToolbar; use super::visibility::update_syntax_visibility; #[allow(unused_imports)] -use super::writer::{EditorImageResolver, SyntaxSpanInfo}; +use super::writer::EditorImageResolver; +#[allow(unused_imports)] +use super::writer::SyntaxSpanInfo; use crate::auth::AuthState; use crate::components::collab::CollaboratorAvatars; use crate::components::editor::ReportButton; diff --git a/crates/weaver-app/src/components/editor/writer.rs b/crates/weaver-app/src/components/editor/writer.rs index e35382a..4d88fd7 100644 --- a/crates/weaver-app/src/components/editor/writer.rs +++ b/crates/weaver-app/src/components/editor/writer.rs @@ -5,85 +5,24 @@ //! //! Uses Parser::into_offset_iter() to track gaps between events, which //! represent consumed formatting characters. +pub mod embed; +pub mod segmented; +pub mod syntax; +pub mod tags; + +use crate::components::editor::writer::segmented::SegmentedWriter; +pub use embed::{EditorImageResolver, EmbedContentProvider, ImageResolver}; +pub use syntax::{SyntaxSpanInfo, SyntaxType}; + #[allow(unused_imports)] use super::offset_map::{OffsetMapping, RenderResult}; -use jacquard::types::{ident::AtIdentifier, string::Rkey}; use loro::LoroText; -use markdown_weaver::{ - Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, - WeaverAttributes, -}; -use markdown_weaver_escape::{ - StrWrite, escape_href, escape_html, escape_html_body_text, - escape_html_body_text_with_char_count, -}; +use markdown_weaver::{Alignment, CowStr, Event, WeaverAttributes}; +use markdown_weaver_escape::{StrWrite, escape_html, escape_html_body_text_with_char_count}; use std::collections::HashMap; use std::fmt; use std::ops::Range; -use weaver_common::{EntryIndex, ResolvedContent}; - -/// Writer that segments output by paragraph boundaries. -/// -/// Each paragraph's HTML is written to a separate String in the segments Vec. -/// Call `new_segment()` at paragraph boundaries to start a new segment. -#[derive(Debug, Clone, Default)] -pub struct SegmentedWriter { - segments: Vec, -} - -#[allow(dead_code)] -impl SegmentedWriter { - pub fn new() -> Self { - Self { - segments: vec![String::new()], - } - } - - /// Start a new segment for the next paragraph. - pub fn new_segment(&mut self) { - self.segments.push(String::new()); - } - - /// Get the completed segments. - pub fn into_segments(self) -> Vec { - self.segments - } - - /// Get current segment count. - pub fn segment_count(&self) -> usize { - self.segments.len() - } -} - -impl StrWrite for SegmentedWriter { - type Error = fmt::Error; - - #[inline] - fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { - if let Some(segment) = self.segments.last_mut() { - segment.push_str(s); - } - Ok(()) - } - - #[inline] - fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> { - if let Some(segment) = self.segments.last_mut() { - fmt::Write::write_fmt(segment, args)?; - } - Ok(()) - } -} - -impl fmt::Write for SegmentedWriter { - fn write_str(&mut self, s: &str) -> fmt::Result { - ::write_str(self, s) - } - - fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { - ::write_fmt(self, args) - } -} +use weaver_common::EntryIndex; /// Result of rendering with the EditorWriter. #[derive(Debug, Clone)] @@ -106,277 +45,6 @@ pub struct WriterResult { pub collected_refs_by_paragraph: Vec>, } -/// Classification of markdown syntax characters -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SyntaxType { - /// Inline formatting: **, *, ~~, `, $, [, ], (, ) - Inline, - /// Block formatting: #, >, -, *, 1., ```, --- - Block, -} - -/// Information about a syntax span for conditional visibility -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SyntaxSpanInfo { - /// Unique identifier for this syntax span (e.g., "s0", "s1") - pub syn_id: String, - /// Source char range this syntax covers (just this marker) - pub char_range: Range, - /// Whether this is inline or block-level syntax - pub syntax_type: SyntaxType, - /// For paired inline syntax (**, *, etc), the full formatted region - /// from opening marker through content to closing marker. - /// When cursor is anywhere in this range, the syntax is visible. - pub formatted_range: Option>, -} - -impl SyntaxSpanInfo { - /// Adjust all position fields by a character delta. - /// - /// This adjusts both `char_range` and `formatted_range` (if present) together, - /// ensuring they stay in sync. Use this instead of manually adjusting fields - /// to avoid forgetting one. - pub fn adjust_positions(&mut self, char_delta: isize) { - self.char_range.start = (self.char_range.start as isize + char_delta) as usize; - self.char_range.end = (self.char_range.end as isize + char_delta) as usize; - if let Some(ref mut fr) = self.formatted_range { - fr.start = (fr.start as isize + char_delta) as usize; - fr.end = (fr.end as isize + char_delta) as usize; - } - } -} - -/// Classify syntax text as inline or block level -fn classify_syntax(text: &str) -> SyntaxType { - let trimmed = text.trim_start(); - - // Check for block-level markers - if trimmed.starts_with('#') - || trimmed.starts_with('>') - || trimmed.starts_with("```") - || trimmed.starts_with("---") - || (trimmed.starts_with('-') - && trimmed - .chars() - .nth(1) - .map(|c| c.is_whitespace()) - .unwrap_or(false)) - || (trimmed.starts_with('*') - && trimmed - .chars() - .nth(1) - .map(|c| c.is_whitespace()) - .unwrap_or(false)) - || trimmed - .chars() - .next() - .map(|c| c.is_ascii_digit()) - .unwrap_or(false) - && trimmed.contains('.') - { - SyntaxType::Block - } else { - SyntaxType::Inline - } -} - -/// Synchronous callback for injecting embed content -/// -/// Takes the embed tag and returns optional HTML content to inject. -pub trait EmbedContentProvider { - fn get_embed_content(&self, tag: &Tag<'_>) -> Option; -} - -impl EmbedContentProvider for () { - fn get_embed_content(&self, _tag: &Tag<'_>) -> Option { - None - } -} - -impl EmbedContentProvider for &ResolvedContent { - fn get_embed_content(&self, tag: &Tag<'_>) -> Option { - if let Tag::Embed { dest_url, .. } = tag { - let url = dest_url.as_ref(); - if url.starts_with("at://") { - if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { - return ResolvedContent::get_embed_content(self, &at_uri) - .map(|s| s.to_string()); - } - } - } - None - } -} - -/// Resolves image URLs to CDN URLs based on stored images. -/// -/// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). -/// This trait maps those names to the actual CDN URL using the blob CID and owner DID. -pub trait ImageResolver { - /// Resolve an image URL from markdown to a CDN URL. - /// - /// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL. - fn resolve_image_url(&self, url: &str) -> Option; -} - -impl ImageResolver for () { - fn resolve_image_url(&self, _url: &str) -> Option { - None - } -} - -/// Concrete image resolver that maps image names to URLs. -/// -/// 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: std::collections::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 { - use jacquard::IntoStatic; - - 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)) - } - } - } -} - -impl ImageResolver for &EditorImageResolver { - fn resolve_image_url(&self, url: &str) -> Option { - (*self).resolve_image_url(url) - } -} - /// Tracks the type of wrapper element emitted for WeaverBlock prefix #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WrapperElement { @@ -384,6 +52,12 @@ enum WrapperElement { Div, } +#[derive(Debug, Clone, Copy)] +pub enum TableState { + Head, + Body, +} + /// HTML writer that preserves markdown formatting characters. /// /// This writer processes offset-iter events to detect gaps (consumed formatting) @@ -478,12 +152,6 @@ pub struct EditorWriter<'a, I: Iterator, Range)>, E = ( _phantom: std::marker::PhantomData<&'a ()>, } -#[derive(Debug, Clone, Copy)] -enum TableState { - Head, - Body, -} - impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> EditorWriter<'a, I, E, R> { @@ -756,182 +424,6 @@ impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, } } - /// Emit syntax span for a given range and record offset mapping - 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.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` - 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.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 - 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.render_tables_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_buffer.is_some() { - return Ok(()); - } - - if next_offset > self.last_byte_offset { - self.emit_syntax(self.last_byte_offset..next_offset)?; - } - Ok(()) - } - /// Generate a unique node ID. /// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`. /// Otherwise produces `n{counter}` for backwards compatibility. @@ -1226,7 +718,10 @@ impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, let key = key.trim(); let value = value.trim(); if !key.is_empty() && !value.is_empty() { - attrs.push((CowStr::from(key.to_string()), CowStr::from(value.to_string()))); + attrs.push(( + CowStr::from(key.to_string()), + CowStr::from(value.to_string()), + )); } } else { // No colon - treat as class, strip leading dot if present @@ -1854,1606 +1349,4 @@ impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, } Ok(()) } - - 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.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.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 - // BUT skip if inside a list - list owns the paragraph boundary - if self.list_depth == 0 { - self.current_paragraph_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#"\n

"#, - 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.offset_maps.push(mapping); - - Ok(()) - } - Tag::Paragraph(_) => { - // Handle wrapper before block - self.emit_wrapper_start()?; - - // Record paragraph start for boundary tracking - // BUT skip if inside a list - list owns the paragraph boundary - if self.list_depth == 0 { - self.current_paragraph_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, "

", node_id)?; - } else { - write!(&mut self.writer, "\n

", 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.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.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - - if !self.end_newline { - self.write("\n")?; - } - - // Generate node ID for offset tracking - let node_id = self.gen_node_id(); - - 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("\"")?; - } - 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.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.render_tables_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.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - self.table_state = TableState::Head; - self.table_cell_index = 0; - self.write("") - } - } - Tag::TableRow => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - self.table_cell_index = 0; - self.write("") - } - } - Tag::TableCell => { - if self.render_tables_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, "\n", class_str)?; - } else { - write!(&mut self.writer, "\n\n", 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.current_paragraph_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("\n")?; - - // Track opening span index for formatted_range update later - self.code_block_opening_span_idx = Some(self.syntax_spans.len()); - self.code_block_char_start = Some(char_start); - - self.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(lang.to_string()) - }; - // Start buffering - self.code_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_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.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - self.list_depth += 1; - if self.end_newline { - self.write("
    \n") - } else { - self.write("\n
      \n") - } - } - Tag::List(Some(start)) => { - self.emit_wrapper_start()?; - // Track list as paragraph-level block - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - self.list_depth += 1; - if self.end_newline { - self.write("
        \n") - } - Tag::List(None) => { - self.emit_wrapper_start()?; - // Track list as paragraph-level block - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); - self.list_depth += 1; - if self.end_newline { - self.write("
\n") - } - } - TagEnd::TableHead => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - self.write("\n")?; - self.table_state = TableState::Body; - Ok(()) - } - } - TagEnd::TableRow => { - if self.render_tables_as_markdown { - Ok(()) // Skip HTML rendering - } else { - self.write("\n") - } - } - TagEnd::TableCell => { - if self.render_tables_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, "

\n")?; - 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("\n")?; - 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_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_buffer_byte_range.take(), - self.code_buffer_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("
\n")?; - } - } - } 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("
\n")?; - } - - // End node tracking - self.end_node(); - } else { - self.write("
\n")?; - } - - // 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_char_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.syntax_spans.get_mut(idx) { - span.formatted_range = Some(fr.clone()); - } - } - - // Push closing fence span with formatted_range - self.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.current_paragraph_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.list_depth = self.list_depth.saturating_sub(1); - // Capture paragraph boundary BEFORE writing closing HTML - let para_boundary = - self.current_paragraph_start - .take() - .map(|(byte_start, char_start)| { - ( - byte_start..self.last_byte_offset, - char_start..self.last_char_offset, - ) - }); - - self.write("\n")?; - 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.list_depth = self.list_depth.saturating_sub(1); - // Capture paragraph boundary BEFORE writing closing HTML - let para_boundary = - self.current_paragraph_start - .take() - .map(|(byte_start, char_start)| { - ( - byte_start..self.last_byte_offset, - char_start..self.last_char_offset, - ) - }); - - self.write("\n")?; - 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("\n") - } - TagEnd::DefinitionList => { - self.write("\n")?; - self.close_wrapper() - } - TagEnd::DefinitionListTitle => { - self.end_node(); - self.write("\n") - } - TagEnd::DefinitionListDefinition => { - self.end_node(); - self.write("\n") - } - TagEnd::Emphasis => { - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag - self.write("
")?; - self.emit_gap_before(range.end)?; - self.finalize_paired_inline_format(); - 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.finalize_paired_inline_format(); - Ok(()) - } - TagEnd::Strikethrough => { - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag - self.write("
")?; - self.emit_gap_before(range.end)?; - self.finalize_paired_inline_format(); - 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.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.finalize_paired_inline_format(); - 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.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.pending_block_attrs { - existing.classes.extend(parsed.classes); - existing.attrs.extend(parsed.attrs); - } else { - self.pending_block_attrs = Some(parsed); - } - } - - Ok(()) - } - TagEnd::FootnoteDefinition => { - self.write("\n")?; - - // Link the footnote definition span with its reference span - if let Some((name, def_span_index, _def_char_start)) = - self.current_footnote_def.take() - { - let def_char_end = self.last_char_offset; - - // Look up the reference span - if let Some(&(ref_span_index, ref_char_start)) = - self.footnote_ref_spans.get(&name) - { - // Create formatted_range spanning from ref start to def end - let formatted_range = ref_char_start..def_char_end; - - // Update both spans with the same formatted_range - // so they show/hide together based on cursor proximity - if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) { - ref_span.formatted_range = Some(formatted_range.clone()); - } - if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) { - def_span.formatted_range = Some(formatted_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(()) - } -} - -impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> - EditorWriter<'a, I, E, R> -{ - 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.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.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.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 - let content = if let Some(content) = content_from_attrs { - Some(content) - } else if let Some(ref provider) = self.embed_provider { - let tag = Tag::Embed { - embed_type, - dest_url: dest_url.clone(), - title: title.clone(), - id: id.clone(), - attrs: attrs.clone(), - }; - provider.get_embed_content(&tag) - } else { - None - }; - - if let Some(html_content) = content { - // Write the pre-rendered content directly - self.write(&html_content)?; - } else { - // Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs) - 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-app/src/components/editor/writer/embed.rs b/crates/weaver-app/src/components/editor/writer/embed.rs new file mode 100644 index 0000000..cbbd5f2 --- /dev/null +++ b/crates/weaver-app/src/components/editor/writer/embed.rs @@ -0,0 +1,371 @@ +use core::fmt; +use std::ops::Range; + +use jacquard::types::{ident::AtIdentifier, string::Rkey}; +use markdown_weaver::{CowStr, EmbedType, Event, Tag}; +use markdown_weaver_escape::{StrWrite, escape_html}; +use weaver_common::ResolvedContent; + +use crate::components::editor::{ + SyntaxSpanInfo, SyntaxType, document::EditorImage, writer::EditorWriter, +}; + +/// Synchronous callback for injecting embed content +/// +/// Takes the embed tag and returns optional HTML content to inject. +pub trait EmbedContentProvider { + fn get_embed_content(&self, tag: &Tag<'_>) -> Option; +} + +impl EmbedContentProvider for () { + fn get_embed_content(&self, _tag: &Tag<'_>) -> Option { + None + } +} + +impl EmbedContentProvider for &ResolvedContent { + fn get_embed_content(&self, tag: &Tag<'_>) -> Option { + if let Tag::Embed { dest_url, .. } = tag { + let url = dest_url.as_ref(); + if url.starts_with("at://") { + if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { + return ResolvedContent::get_embed_content(self, &at_uri) + .map(|s| s.to_string()); + } + } + } + None + } +} + +/// Resolves image URLs to CDN URLs based on stored images. +/// +/// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). +/// This trait maps those names to the actual CDN URL using the blob CID and owner DID. +pub trait ImageResolver { + /// Resolve an image URL from markdown to a CDN URL. + /// + /// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL. + fn resolve_image_url(&self, url: &str) -> Option; +} + +impl ImageResolver for () { + fn resolve_image_url(&self, _url: &str) -> Option { + None + } +} + +/// Concrete image resolver that maps image names to URLs. +/// +/// 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: std::collections::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 { + use jacquard::IntoStatic; + + 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)) + } + } + } +} + +impl ImageResolver for &EditorImageResolver { + fn resolve_image_url(&self, url: &str) -> Option { + (*self).resolve_image_url(url) + } +} + +impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> + EditorWriter<'a, I, E, R> +{ + 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.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.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.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 + let content = if let Some(content) = content_from_attrs { + Some(content) + } else if let Some(ref provider) = self.embed_provider { + let tag = Tag::Embed { + embed_type, + dest_url: dest_url.clone(), + title: title.clone(), + id: id.clone(), + attrs: attrs.clone(), + }; + provider.get_embed_content(&tag) + } else { + None + }; + + if let Some(html_content) = content { + // Write the pre-rendered content directly + self.write(&html_content)?; + } else { + // Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs) + 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-app/src/components/editor/writer/segmented.rs b/crates/weaver-app/src/components/editor/writer/segmented.rs new file mode 100644 index 0000000..4cd7b96 --- /dev/null +++ b/crates/weaver-app/src/components/editor/writer/segmented.rs @@ -0,0 +1,66 @@ +use core::fmt; + +use markdown_weaver_escape::StrWrite; + +/// Writer that segments output by paragraph boundaries. +/// +/// Each paragraph's HTML is written to a separate String in the segments Vec. +/// Call `new_segment()` at paragraph boundaries to start a new segment. +#[derive(Debug, Clone, Default)] +pub struct SegmentedWriter { + pub segments: Vec, +} + +#[allow(dead_code)] +impl SegmentedWriter { + pub fn new() -> Self { + Self { + segments: vec![String::new()], + } + } + + /// Start a new segment for the next paragraph. + pub fn new_segment(&mut self) { + self.segments.push(String::new()); + } + + /// Get the completed segments. + pub fn into_segments(self) -> Vec { + self.segments + } + + /// Get current segment count. + pub fn segment_count(&self) -> usize { + self.segments.len() + } +} + +impl StrWrite for SegmentedWriter { + type Error = fmt::Error; + + #[inline] + fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { + if let Some(segment) = self.segments.last_mut() { + segment.push_str(s); + } + Ok(()) + } + + #[inline] + fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> { + if let Some(segment) = self.segments.last_mut() { + fmt::Write::write_fmt(segment, args)?; + } + Ok(()) + } +} + +impl fmt::Write for SegmentedWriter { + fn write_str(&mut self, s: &str) -> fmt::Result { + ::write_str(self, s) + } + + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { + ::write_fmt(self, args) + } +} diff --git a/crates/weaver-app/src/components/editor/writer/syntax.rs b/crates/weaver-app/src/components/editor/writer/syntax.rs new file mode 100644 index 0000000..be2903c --- /dev/null +++ b/crates/weaver-app/src/components/editor/writer/syntax.rs @@ -0,0 +1,264 @@ +use core::fmt; +use std::ops::Range; + +use markdown_weaver::Event; +use markdown_weaver_escape::{StrWrite, escape_html}; + +use crate::components::editor::writer::{ + EditorWriter, + embed::{EmbedContentProvider, ImageResolver}, +}; + +/// Classification of markdown syntax characters +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SyntaxType { + /// Inline formatting: **, *, ~~, `, $, [, ], (, ) + Inline, + /// Block formatting: #, >, -, *, 1., ```, --- + Block, +} + +/// Information about a syntax span for conditional visibility +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntaxSpanInfo { + /// Unique identifier for this syntax span (e.g., "s0", "s1") + pub syn_id: String, + /// Source char range this syntax covers (just this marker) + pub char_range: Range, + /// Whether this is inline or block-level syntax + pub syntax_type: SyntaxType, + /// For paired inline syntax (**, *, etc), the full formatted region + /// from opening marker through content to closing marker. + /// When cursor is anywhere in this range, the syntax is visible. + pub formatted_range: Option>, +} + +impl SyntaxSpanInfo { + /// Adjust all position fields by a character delta. + /// + /// This adjusts both `char_range` and `formatted_range` (if present) together, + /// ensuring they stay in sync. Use this instead of manually adjusting fields + /// to avoid forgetting one. + pub fn adjust_positions(&mut self, char_delta: isize) { + self.char_range.start = (self.char_range.start as isize + char_delta) as usize; + self.char_range.end = (self.char_range.end as isize + char_delta) as usize; + if let Some(ref mut fr) = self.formatted_range { + fr.start = (fr.start as isize + char_delta) as usize; + fr.end = (fr.end as isize + char_delta) as usize; + } + } +} + +/// Classify syntax text as inline or block level +pub(crate) fn classify_syntax(text: &str) -> SyntaxType { + let trimmed = text.trim_start(); + + // Check for block-level markers + if trimmed.starts_with('#') + || trimmed.starts_with('>') + || trimmed.starts_with("```") + || trimmed.starts_with("---") + || (trimmed.starts_with('-') + && trimmed + .chars() + .nth(1) + .map(|c| c.is_whitespace()) + .unwrap_or(false)) + || (trimmed.starts_with('*') + && trimmed + .chars() + .nth(1) + .map(|c| c.is_whitespace()) + .unwrap_or(false)) + || trimmed + .chars() + .next() + .map(|c| c.is_ascii_digit()) + .unwrap_or(false) + && trimmed.contains('.') + { + SyntaxType::Block + } else { + SyntaxType::Inline + } +} + +impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> + EditorWriter<'a, I, E, R> +{ + /// 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.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.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.render_tables_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_buffer.is_some() { + return Ok(()); + } + + if next_offset > self.last_byte_offset { + self.emit_syntax(self.last_byte_offset..next_offset)?; + } + Ok(()) + } +} diff --git a/crates/weaver-app/src/components/editor/writer/tags.rs b/crates/weaver-app/src/components/editor/writer/tags.rs new file mode 100644 index 0000000..e276992 --- /dev/null +++ b/crates/weaver-app/src/components/editor/writer/tags.rs @@ -0,0 +1,1464 @@ +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::components::editor::{ + OffsetMapping, SyntaxSpanInfo, SyntaxType, + writer::{ + EditorWriter, TableState, + embed::{EmbedContentProvider, ImageResolver}, + syntax::classify_syntax, + }, +}; + +impl<'a, I: Iterator, Range)>, E: EmbedContentProvider, R: ImageResolver> + EditorWriter<'a, I, E, R> +{ + 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.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.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 + // BUT skip if inside a list - list owns the paragraph boundary + if self.list_depth == 0 { + self.current_paragraph_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#"\n

"#, + 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.offset_maps.push(mapping); + + Ok(()) + } + Tag::Paragraph(_) => { + // Handle wrapper before block + self.emit_wrapper_start()?; + + // Record paragraph start for boundary tracking + // BUT skip if inside a list - list owns the paragraph boundary + if self.list_depth == 0 { + self.current_paragraph_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, "

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

", 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.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.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + + if !self.end_newline { + self.write("\n")?; + } + + // Generate node ID for offset tracking + let node_id = self.gen_node_id(); + + 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("\"")?; + } + 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.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.render_tables_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.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.table_state = TableState::Head; + self.table_cell_index = 0; + self.write("") + } + } + Tag::TableRow => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.table_cell_index = 0; + self.write("") + } + } + Tag::TableCell => { + if self.render_tables_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, "\n", class_str)?; + } else { + write!(&mut self.writer, "\n\n", 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.current_paragraph_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("\n")?; + + // Track opening span index for formatted_range update later + self.code_block_opening_span_idx = Some(self.syntax_spans.len()); + self.code_block_char_start = Some(char_start); + + self.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(lang.to_string()) + }; + // Start buffering + self.code_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_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.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + self.list_depth += 1; + if self.end_newline { + self.write("
    \n") + } else { + self.write("\n
      \n") + } + } + Tag::List(Some(start)) => { + self.emit_wrapper_start()?; + // Track list as paragraph-level block + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + self.list_depth += 1; + if self.end_newline { + self.write("
        \n") + } + Tag::List(None) => { + self.emit_wrapper_start()?; + // Track list as paragraph-level block + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); + self.list_depth += 1; + if self.end_newline { + self.write("
\n") + } + } + TagEnd::TableHead => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.write("\n")?; + self.table_state = TableState::Body; + Ok(()) + } + } + TagEnd::TableRow => { + if self.render_tables_as_markdown { + Ok(()) // Skip HTML rendering + } else { + self.write("\n") + } + } + TagEnd::TableCell => { + if self.render_tables_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, "

\n")?; + 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("\n")?; + 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_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_buffer_byte_range.take(), + self.code_buffer_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("
\n")?; + } + } + } 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("
\n")?; + } + + // End node tracking + self.end_node(); + } else { + self.write("
\n")?; + } + + // 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_char_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.syntax_spans.get_mut(idx) { + span.formatted_range = Some(fr.clone()); + } + } + + // Push closing fence span with formatted_range + self.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.current_paragraph_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.list_depth = self.list_depth.saturating_sub(1); + // Capture paragraph boundary BEFORE writing closing HTML + let para_boundary = + self.current_paragraph_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }); + + self.write("\n")?; + 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.list_depth = self.list_depth.saturating_sub(1); + // Capture paragraph boundary BEFORE writing closing HTML + let para_boundary = + self.current_paragraph_start + .take() + .map(|(byte_start, char_start)| { + ( + byte_start..self.last_byte_offset, + char_start..self.last_char_offset, + ) + }); + + self.write("\n")?; + 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("\n") + } + TagEnd::DefinitionList => { + self.write("\n")?; + self.close_wrapper() + } + TagEnd::DefinitionListTitle => { + self.end_node(); + self.write("\n") + } + TagEnd::DefinitionListDefinition => { + self.end_node(); + self.write("\n") + } + TagEnd::Emphasis => { + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag + self.write("
")?; + self.emit_gap_before(range.end)?; + self.finalize_paired_inline_format(); + 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.finalize_paired_inline_format(); + Ok(()) + } + TagEnd::Strikethrough => { + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag + self.write("
")?; + self.emit_gap_before(range.end)?; + self.finalize_paired_inline_format(); + 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.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.finalize_paired_inline_format(); + 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.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.pending_block_attrs { + existing.classes.extend(parsed.classes); + existing.attrs.extend(parsed.attrs); + } else { + self.pending_block_attrs = Some(parsed); + } + } + + Ok(()) + } + TagEnd::FootnoteDefinition => { + self.write("\n")?; + + // Link the footnote definition span with its reference span + if let Some((name, def_span_index, _def_char_start)) = + self.current_footnote_def.take() + { + let def_char_end = self.last_char_offset; + + // Look up the reference span + if let Some(&(ref_span_index, ref_char_start)) = + self.footnote_ref_spans.get(&name) + { + // Create formatted_range spanning from ref start to def end + let formatted_range = ref_char_start..def_char_end; + + // Update both spans with the same formatted_range + // so they show/hide together based on cursor proximity + if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) { + ref_span.formatted_range = Some(formatted_range.clone()); + } + if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) { + def_span.formatted_range = Some(formatted_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(()) + } +} diff --git a/crates/weaver-app/src/components/entry.rs b/crates/weaver-app/src/components/entry.rs index 9ef4517..fae6a54 100644 --- a/crates/weaver-app/src/components/entry.rs +++ b/crates/weaver-app/src/components/entry.rs @@ -212,9 +212,6 @@ fn EntryPageView( tracing::info!("Entry: {book_title} - {title}"); - let prev_entry = book_entry_view().prev.clone(); - let next_entry = book_entry_view().next.clone(); - rsx! { EntryOgMeta { title: title.to_string(), @@ -229,13 +226,15 @@ fn EntryPageView( div { class: "entry-page", // Header: nav prev + metadata + nav next header { class: "entry-header", - if let Some(ref prev) = prev_entry { + if let Some(ref prev) = book_entry_view().prev { NavButton { direction: "prev", entry: prev.entry.clone(), ident: ident(), book_title: book_title() } + } else { + div { class: "nav-placeholder" } } { @@ -253,13 +252,15 @@ fn EntryPageView( } } - if let Some(ref next) = next_entry { + if let Some(ref next) = book_entry_view().next { NavButton { direction: "next", entry: next.entry.clone(), ident: ident(), book_title: book_title() } + } else { + div { class: "nav-placeholder" } } } @@ -275,7 +276,7 @@ fn EntryPageView( // Footer navigation footer { class: "entry-footer-nav", - if let Some(ref prev) = prev_entry { + if let Some(ref prev) = book_entry_view().prev { NavButton { direction: "prev", entry: prev.entry.clone(), @@ -284,7 +285,7 @@ fn EntryPageView( } } - if let Some(ref next) = next_entry { + if let Some(ref next) = book_entry_view().next { NavButton { direction: "next", entry: next.entry.clone(), @@ -726,7 +727,19 @@ pub struct EntryMarkdownProps { /// Render some text as markdown. pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element { - let (_res, processed) = crate::data::use_rendered_markdown(props.content, props.ident); + let (mut _res, processed) = crate::data::use_rendered_markdown(props.content, props.ident); + + // Track entry title to detect content change and restart resource + let mut last_title = use_signal(|| (props.content)().title.to_string()); + let current_title = (props.content)().title.to_string(); + if current_title != last_title() { + #[cfg(feature = "fullstack-server")] + if let Ok(ref mut r) = _res { + r.restart(); + } + last_title.set(current_title); + } + #[cfg(feature = "fullstack-server")] _res?; diff --git a/crates/weaver-app/src/data.rs b/crates/weaver-app/src/data.rs index 1d96bf6..7d3b34e 100644 --- a/crates/weaver-app/src/data.rs +++ b/crates/weaver-app/src/data.rs @@ -421,7 +421,7 @@ pub fn use_rendered_markdown( ) -> (Resource>, Memo>) { let fetcher = use_context::(); let fetcher = fetcher.clone(); - let res = use_resource(move || { + let res = use_resource(use_reactive!(|(content, ident)| { let fetcher = fetcher.clone(); async move { let entry = content(); @@ -434,14 +434,14 @@ pub fn use_rendered_markdown( Some(render_markdown_impl(entry, did, resolved_content).await) } - }); - let memo = use_memo(move || { + })); + let memo = use_memo(use_reactive!(|res| { if let Some(Some(value)) = &*res.read() { Some(value.clone()) } else { None } - }); + })); (res, memo) } diff --git a/crates/weaver-index/src/endpoints/notebook.rs b/crates/weaver-index/src/endpoints/notebook.rs index f8dae62..9b1ce7f 100644 --- a/crates/weaver-index/src/endpoints/notebook.rs +++ b/crates/weaver-index/src/endpoints/notebook.rs @@ -139,9 +139,9 @@ pub async fn resolve_notebook( .maybe_path(non_empty_cowstr(¬ebook_row.path)) .build(); - // Build entry views - let mut entries: Vec> = Vec::with_capacity(entry_rows.len()); - for (idx, entry_row) in entry_rows.iter().enumerate() { + // Build entry views (first pass: create EntryViews) + let mut entry_views: Vec> = Vec::with_capacity(entry_rows.len()); + for entry_row in entry_rows.iter() { let entry_uri = AtUri::new(&entry_row.uri).map_err(|e| { tracing::error!("Invalid entry URI in db: {}", e); XrpcErrorResponse::internal_error("Invalid URI stored") @@ -185,12 +185,26 @@ pub async fn resolve_notebook( .maybe_path(non_empty_cowstr(&entry_row.path)) .build(); - let book_entry = BookEntryView::new() - .entry(entry_view) - .index(idx as i64) - .build(); + entry_views.push(entry_view); + } - entries.push(book_entry); + // Build BookEntryViews with prev/next navigation + let mut entries: Vec> = Vec::with_capacity(entry_views.len()); + for (idx, entry_view) in entry_views.iter().enumerate() { + let prev = (idx > 0) + .then(|| BookEntryRef::new().entry(entry_views[idx - 1].clone()).build()); + let next = entry_views + .get(idx + 1) + .map(|e| BookEntryRef::new().entry(e.clone()).build()); + + entries.push( + BookEntryView::new() + .entry(entry_view.clone()) + .index(idx as i64) + .maybe_prev(prev) + .maybe_next(next) + .build(), + ); } // Build cursor for pagination (position-based) diff --git a/crates/weaver-renderer/src/css.rs b/crates/weaver-renderer/src/css.rs index d41ae9c..1d7d78a 100644 --- a/crates/weaver-renderer/src/css.rs +++ b/crates/weaver-renderer/src/css.rs @@ -322,8 +322,9 @@ tr:hover {{ color: var(--color-primary); }} -/* Aside blocks (via WeaverBlock prefix) */ -aside, .aside {{ +/* Aside blocks (via WeaverBlock prefix) - scoped to notebook content */ +.notebook-content aside, +.notebook-content .aside {{ float: left; width: 40%; margin: 0 1.5rem 1rem 0; @@ -334,19 +335,19 @@ aside, .aside {{ clear: left; }} -aside > *:first-child, -.aside > *:first-child {{ +.notebook-content aside > *:first-child, +.notebook-content .aside > *:first-child {{ margin-top: 0; }} -aside > *:last-child, -.aside > *:last-child {{ +.notebook-content aside > *:last-child, +.notebook-content .aside > *:last-child {{ margin-bottom: 0; }} /* Reset blockquote styling inside asides */ -aside > blockquote, -.aside > blockquote {{ +.notebook-content aside > blockquote, +.notebook-content .aside > blockquote {{ border-left: none; background: transparent; padding: 0; diff --git a/test-sidenotes.html b/test-sidenotes.html new file mode 100644 index 0000000..ce714ec --- /dev/null +++ b/test-sidenotes.html @@ -0,0 +1,1279 @@ + + + + + + test-sidenotes + + + + +
+

Weaver: Long-form Writing on AT Protocol

+

Or: "Get in kid, we're rebuilding the blogosphere!"

+

I grew up, like a lot of people on Bluesky, in the era of the internet where most of your online social interactions took place via text. I had a MySpace account, MSN messenger and Google Chat, I first got on Facebook back when they required a school email to sign up, I had a Tumblr, though not a LiveJournal.Hi Rahaeli. Sorry I was the wrong kind of nerd.

+
+

weaver_photo_med.jpgThe namesake of what I'm building

+
+

Social media in the conventional sense has been in a lot of ways a small part of the story of my time on the internet. The broader independent blogosphere of my teens and early adulthood shaped my worldview, and I was an avid reader and sometime participant there.

+

The Blogosphere

+

I am an atheist in large part because of a blog called Common Sense Atheism.The author, Luke Muehlhauser, was criticising both Richard Dawkins and some Christian apologetics I was familiar with. Luke's blog was part of a cluster of blogs out of which grew the rationalists, one of, for better or for worse, the most influential intellectual movements of the 21st century.I also read blogs like boingboing.net, was a big fan of Cory Doctorow. I figured out I am trans in part because of Thing of Things,Specifically their piece on the cluster structure of genderspace. a blog by Ozy Frantz, a transmasc person in the broader rationalist and Effective Altruist blogosphere.One thing these all have in common is length. Part of the reason I only really got onto Twitter in 2020 or so was because the concept of microblogging, of having to fit your thoughts into such a small package, baffled me for ages.Amusingly I now think that being on Twitter and now Bluesky made me a better writer. Restrictions breed creativity, after all.

+ +

But at the same time, Substack's success proves that there is very much a desire for long-form writing, enough that people will pay for it, and that investors will back it. There are thoughts and forms of writing that you simply cannot fit into a post or even a thread of posts.

+

Plus, I'm loathe to enable a centralised platform like Substack where the owners are unfortunately friendly to fascists.I am very much a fan of freedom of expression. I'm not so much a fan of paying money to Nazis.That's where the at:// protocol and Weaver comes in.

+

The Pitch

+

Weaver is designed to be a highly flexible platform for medium and long-form writing on atproto.The weaver bird builds intricate, self-contained homes—seemed fitting for a platform about owning your writing. I was inspired by how weaver birds build their own homes, and by the notebooks, physical and virtual, that I create in the course of my work.The initial proof-of-concept is essentially a static site generator, able to turn a Markdown text file or a folder of Markdown files into a static "notebook" site. The intermediate goal is an elegant and intuitive writing platform with collaborative editing and straightforward, immediate publishing via a web-app.

+ +

How It Works

+

Weaver works on a concept of notebooks with entries, which can be grouped into pages or chapters. They can have multiple attributed authors. You can tear out a metaphorical page and stick it in another notebook.

+

You own what you write.Technically you can include entries you don't control in your notebooks, although this isn't a supported mode—it's about your ownership of your words. And once collaborative editing is in, collaborative work will be resilient against deletion by one author. They can delete their notebook or even their account, but what you write will be safe.Entries are Markdown text—specifically, an extension on the Obsidian flavour of Markdown.I forked the popular rust markdown processing library pulldown-cmark because it had limited extensibility along the axes I wanted—custom syntax extensions to support Obsidian's Markdown flavour and additional useful features, like some of the ones on show here! They support additional embed types, including atproto record embeds and other markdown documents, as well as resizable images.

+

Why Rust?

+

As to why I'm writing it in Rust (and currently zero Typescript) as opposed to Go and Typescript? Well it comes down to familiarity. Rust isn't necessarily anyone's first choice in a vacuum for a web-native programming language, but it works quite well as one. I can share the vast majority of the protocol code, as well as the markdown rendering engine, between front and back end, with few if any compromises on performance, save a larger bundle size due to the nature of WebAssembly.

+ +

Evolution

+

Weaver is therefore very much an evolving thing. It will always have and support the proof-of-concept workflow as a first-class citizen. That's part of the benefit of building this on atproto.

+

If I screw this up, not too hard for someone else to pick up the torch and continue.This is the traditional footnote, at the end, because sometimes you want your citations at the bottom of the page rather than in the margins.

+
+ +