broke up mega-writer file in editor

Orual fb1645bc d22836ce

+3573 -2226
+33 -37
Cargo.lock
··· 980 980 981 981 [[package]] 982 982 name = "cc" 983 - version = "1.2.49" 983 + version = "1.2.50" 984 984 source = "registry+https://github.com/rust-lang/crates.io-index" 985 - checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" 985 + checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" 986 986 dependencies = [ 987 987 "find-msvc-tools", 988 988 "jobserver", ··· 2805 2805 [[package]] 2806 2806 name = "dioxus-primitives" 2807 2807 version = "0.0.1" 2808 - source = "git+https://github.com/DioxusLabs/components#3564270718866d2e886f879973afc77d7c3a1689" 2808 + source = "git+https://github.com/DioxusLabs/components#545aa7f55205b488f6403f92133fffb6c66838de" 2809 2809 dependencies = [ 2810 2810 "dioxus 0.7.2", 2811 2811 "dioxus-sdk-time", ··· 5568 5568 5569 5569 [[package]] 5570 5570 name = "jacquard" 5571 - version = "0.9.4" 5572 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5571 + version = "0.9.5" 5572 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5573 5573 dependencies = [ 5574 5574 "bytes", 5575 5575 "getrandom 0.2.16", ··· 5585 5585 "regex", 5586 5586 "regex-lite", 5587 5587 "reqwest", 5588 - "ring", 5589 5588 "serde", 5590 5589 "serde_html_form", 5591 5590 "serde_json", ··· 5600 5599 5601 5600 [[package]] 5602 5601 name = "jacquard-api" 5603 - version = "0.9.2" 5604 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5602 + version = "0.9.5" 5603 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5605 5604 dependencies = [ 5606 5605 "bon", 5607 5606 "bytes", ··· 5619 5618 5620 5619 [[package]] 5621 5620 name = "jacquard-axum" 5622 - version = "0.9.2" 5623 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5621 + version = "0.9.6" 5622 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5624 5623 dependencies = [ 5625 5624 "axum", 5626 5625 "bytes", ··· 5641 5640 5642 5641 [[package]] 5643 5642 name = "jacquard-common" 5644 - version = "0.9.2" 5645 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5643 + version = "0.9.5" 5644 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5646 5645 dependencies = [ 5647 5646 "base64 0.22.1", 5648 5647 "bon", ··· 5669 5668 "regex", 5670 5669 "regex-lite", 5671 5670 "reqwest", 5672 - "ring", 5673 5671 "serde", 5674 5672 "serde_bytes", 5675 5673 "serde_html_form", ··· 5689 5687 5690 5688 [[package]] 5691 5689 name = "jacquard-derive" 5692 - version = "0.9.4" 5693 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5690 + version = "0.9.5" 5691 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5694 5692 dependencies = [ 5695 5693 "heck 0.5.0", 5696 5694 "jacquard-lexicon", ··· 5701 5699 5702 5700 [[package]] 5703 5701 name = "jacquard-identity" 5704 - version = "0.9.2" 5705 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5702 + version = "0.9.5" 5703 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5706 5704 dependencies = [ 5707 5705 "bon", 5708 5706 "bytes", ··· 5712 5710 "jacquard-common", 5713 5711 "jacquard-lexicon", 5714 5712 "miette 7.6.0", 5715 - "mini-moka 0.10.99", 5713 + "mini-moka-wasm", 5716 5714 "n0-future 0.1.3", 5717 5715 "percent-encoding", 5718 5716 "reqwest", 5719 - "ring", 5720 5717 "serde", 5721 5718 "serde_html_form", 5722 5719 "serde_json", ··· 5730 5727 5731 5728 [[package]] 5732 5729 name = "jacquard-lexicon" 5733 - version = "0.9.2" 5734 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5730 + version = "0.9.5" 5731 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5735 5732 dependencies = [ 5736 5733 "cid", 5737 5734 "dashmap 6.1.0", ··· 5756 5753 5757 5754 [[package]] 5758 5755 name = "jacquard-oauth" 5759 - version = "0.9.2" 5760 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5756 + version = "0.9.6" 5757 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5761 5758 dependencies = [ 5762 5759 "base64 0.22.1", 5763 5760 "bytes", ··· 5772 5769 "miette 7.6.0", 5773 5770 "p256", 5774 5771 "rand 0.8.5", 5775 - "ring", 5776 5772 "rouille", 5777 5773 "serde", 5778 5774 "serde_html_form", ··· 5789 5785 5790 5786 [[package]] 5791 5787 name = "jacquard-repo" 5792 - version = "0.9.4" 5793 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5788 + version = "0.9.5" 5789 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5794 5790 dependencies = [ 5795 5791 "bytes", 5796 5792 "cid", ··· 6482 6478 [[package]] 6483 6479 name = "markdown-weaver" 6484 6480 version = "0.13.0" 6485 - source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" 6481 + source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#5f4257b7ee3175f73974b43c3269b2130a4479ca" 6486 6482 dependencies = [ 6487 6483 "bitflags 2.10.0", 6488 6484 "getopts", ··· 6495 6491 [[package]] 6496 6492 name = "markdown-weaver-escape" 6497 6493 version = "0.11.0" 6498 - source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" 6494 + source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#5f4257b7ee3175f73974b43c3269b2130a4479ca" 6499 6495 6500 6496 [[package]] 6501 6497 name = "markup5ever" ··· 6747 6743 6748 6744 [[package]] 6749 6745 name = "mini-moka" 6750 - version = "0.10.99" 6751 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 6746 + version = "0.11.0" 6747 + source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d" 6752 6748 dependencies = [ 6753 6749 "crossbeam-channel", 6754 6750 "crossbeam-utils", ··· 6760 6756 ] 6761 6757 6762 6758 [[package]] 6763 - name = "mini-moka" 6764 - version = "0.11.0" 6765 - source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d" 6759 + name = "mini-moka-wasm" 6760 + version = "0.10.99" 6761 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 6766 6762 dependencies = [ 6767 6763 "crossbeam-channel", 6768 6764 "crossbeam-utils", ··· 8030 8026 8031 8027 [[package]] 8032 8028 name = "portable-atomic" 8033 - version = "1.11.1" 8029 + version = "1.12.0" 8034 8030 source = "registry+https://github.com/rust-lang/crates.io-index" 8035 - checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 8031 + checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" 8036 8032 8037 8033 [[package]] 8038 8034 name = "portmapper" ··· 11679 11675 "markdown-weaver", 11680 11676 "markdown-weaver-escape", 11681 11677 "mime-sniffer", 11682 - "mini-moka 0.11.0", 11678 + "mini-moka", 11683 11679 "n0-future 0.1.3", 11684 11680 "postcard", 11685 11681 "regex", ··· 11801 11797 "k256", 11802 11798 "loro", 11803 11799 "miette 7.6.0", 11804 - "mini-moka 0.11.0", 11800 + "mini-moka", 11805 11801 "n0-future 0.1.3", 11806 11802 "rand 0.8.5", 11807 11803 "regex",
+15 -31
crates/weaver-app/assets/styling/entry.css
··· 1 1 /* Entry page layout - centered content with sidenote margin */ 2 2 .entry-page { 3 - --content-width: 65ch; 3 + --content-width: 95ch; 4 4 --sidenote-width: 14rem; 5 5 --sidenote-gap: 1.5rem; 6 6 ··· 39 39 color: var(--color-primary); 40 40 } 41 41 42 - .entry-header .nav-button-prev { 43 - flex-shrink: 1; 42 + .entry-header .nav-button-prev, 43 + .entry-header .nav-placeholder:first-child { 44 + flex: 1; 44 45 min-width: 0; 45 46 } 46 47 47 - .entry-header .nav-button-next { 48 - flex-shrink: 1; 48 + .entry-header .nav-button-next, 49 + .entry-header .nav-placeholder:last-child { 50 + flex: 1; 49 51 min-width: 0; 52 + justify-content: flex-end; 50 53 } 51 54 52 55 .entry-header .nav-arrow { ··· 60 63 text-overflow: ellipsis; 61 64 } 62 65 63 - /* Metadata takes center, flexes to fill */ 66 + /* Metadata anchored to content width */ 64 67 .entry-header .entry-metadata { 65 - flex: 1; 66 - max-width: var(--content-width); 68 + width: min(var(--content-width), 100%); 69 + flex-shrink: 1; 67 70 margin: 0; 68 71 padding: 0; 69 72 border: none; ··· 80 83 .entry-content-main { 81 84 width: var(--content-width); 82 85 max-width: 100%; 86 + border-top: 1px solid var(--color-border); 83 87 position: relative; 84 88 } 85 89 86 - /* When sidenotes exist, shift content left to make room */ 87 - .entry-content-main:has(.sidenote) { 88 - margin-right: calc(var(--sidenote-width) + var(--sidenote-gap)); 89 - } 90 + /* Sidenote layout handled by css.rs body padding */ 90 91 91 92 /* Footer navigation */ 92 93 .entry-footer-nav { ··· 295 296 } 296 297 } 297 298 298 - /* Responsive: when sidenotes need to squeeze */ 299 - @media (max-width: 1000px) { 300 - .entry-content-main:has(.sidenote) { 301 - margin-right: calc(var(--sidenote-width) + 0.5rem); 302 - margin-left: -3rem; 303 - } 304 - } 305 - 306 - /* Responsive: narrower - compress left margin more */ 299 + /* Responsive: narrower */ 307 300 @media (max-width: 900px) { 308 301 .entry-header .nav-title { 309 302 max-width: 8rem; 310 303 } 311 - 312 - .entry-content-main:has(.sidenote) { 313 - margin-left: -5rem; 314 - } 315 304 } 316 305 317 - /* Responsive: mobile - sidenotes go inline */ 306 + /* Responsive: mobile */ 318 307 @media (max-width: 768px) { 319 308 .entry-header { 320 309 flex-direction: column; 321 310 align-items: stretch; 322 311 gap: 0.5rem; 323 - } 324 - 325 - .entry-content-main:has(.sidenote) { 326 - margin-left: 0; 327 - margin-right: 0; 328 312 } 329 313 330 314 .entry-footer-nav {
+3 -1
crates/weaver-app/src/components/editor/component.rs
··· 26 26 use super::toolbar::EditorToolbar; 27 27 use super::visibility::update_syntax_visibility; 28 28 #[allow(unused_imports)] 29 - use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 29 + use super::writer::EditorImageResolver; 30 + #[allow(unused_imports)] 31 + use super::writer::SyntaxSpanInfo; 30 32 use crate::auth::AuthState; 31 33 use crate::components::collab::CollaboratorAvatars; 32 34 use crate::components::editor::ReportButton;
+22 -2129
crates/weaver-app/src/components/editor/writer.rs
··· 5 5 //! 6 6 //! Uses Parser::into_offset_iter() to track gaps between events, which 7 7 //! represent consumed formatting characters. 8 + pub mod embed; 9 + pub mod segmented; 10 + pub mod syntax; 11 + pub mod tags; 12 + 13 + use crate::components::editor::writer::segmented::SegmentedWriter; 14 + pub use embed::{EditorImageResolver, EmbedContentProvider, ImageResolver}; 15 + pub use syntax::{SyntaxSpanInfo, SyntaxType}; 16 + 8 17 #[allow(unused_imports)] 9 18 use super::offset_map::{OffsetMapping, RenderResult}; 10 - use jacquard::types::{ident::AtIdentifier, string::Rkey}; 11 19 use loro::LoroText; 12 - use markdown_weaver::{ 13 - Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag, 14 - WeaverAttributes, 15 - }; 16 - use markdown_weaver_escape::{ 17 - StrWrite, escape_href, escape_html, escape_html_body_text, 18 - escape_html_body_text_with_char_count, 19 - }; 20 + use markdown_weaver::{Alignment, CowStr, Event, WeaverAttributes}; 21 + use markdown_weaver_escape::{StrWrite, escape_html, escape_html_body_text_with_char_count}; 20 22 use std::collections::HashMap; 21 23 use std::fmt; 22 24 use std::ops::Range; 23 - use weaver_common::{EntryIndex, ResolvedContent}; 24 - 25 - /// Writer that segments output by paragraph boundaries. 26 - /// 27 - /// Each paragraph's HTML is written to a separate String in the segments Vec. 28 - /// Call `new_segment()` at paragraph boundaries to start a new segment. 29 - #[derive(Debug, Clone, Default)] 30 - pub struct SegmentedWriter { 31 - segments: Vec<String>, 32 - } 33 - 34 - #[allow(dead_code)] 35 - impl SegmentedWriter { 36 - pub fn new() -> Self { 37 - Self { 38 - segments: vec![String::new()], 39 - } 40 - } 41 - 42 - /// Start a new segment for the next paragraph. 43 - pub fn new_segment(&mut self) { 44 - self.segments.push(String::new()); 45 - } 46 - 47 - /// Get the completed segments. 48 - pub fn into_segments(self) -> Vec<String> { 49 - self.segments 50 - } 51 - 52 - /// Get current segment count. 53 - pub fn segment_count(&self) -> usize { 54 - self.segments.len() 55 - } 56 - } 57 - 58 - impl StrWrite for SegmentedWriter { 59 - type Error = fmt::Error; 60 - 61 - #[inline] 62 - fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { 63 - if let Some(segment) = self.segments.last_mut() { 64 - segment.push_str(s); 65 - } 66 - Ok(()) 67 - } 68 - 69 - #[inline] 70 - fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> { 71 - if let Some(segment) = self.segments.last_mut() { 72 - fmt::Write::write_fmt(segment, args)?; 73 - } 74 - Ok(()) 75 - } 76 - } 77 - 78 - impl fmt::Write for SegmentedWriter { 79 - fn write_str(&mut self, s: &str) -> fmt::Result { 80 - <Self as StrWrite>::write_str(self, s) 81 - } 82 - 83 - fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { 84 - <Self as StrWrite>::write_fmt(self, args) 85 - } 86 - } 25 + use weaver_common::EntryIndex; 87 26 88 27 /// Result of rendering with the EditorWriter. 89 28 #[derive(Debug, Clone)] ··· 106 45 pub collected_refs_by_paragraph: Vec<Vec<weaver_common::ExtractedRef>>, 107 46 } 108 47 109 - /// Classification of markdown syntax characters 110 - #[derive(Debug, Clone, Copy, PartialEq, Eq)] 111 - pub enum SyntaxType { 112 - /// Inline formatting: **, *, ~~, `, $, [, ], (, ) 113 - Inline, 114 - /// Block formatting: #, >, -, *, 1., ```, --- 115 - Block, 116 - } 117 - 118 - /// Information about a syntax span for conditional visibility 119 - #[derive(Debug, Clone, PartialEq, Eq)] 120 - pub struct SyntaxSpanInfo { 121 - /// Unique identifier for this syntax span (e.g., "s0", "s1") 122 - pub syn_id: String, 123 - /// Source char range this syntax covers (just this marker) 124 - pub char_range: Range<usize>, 125 - /// Whether this is inline or block-level syntax 126 - pub syntax_type: SyntaxType, 127 - /// For paired inline syntax (**, *, etc), the full formatted region 128 - /// from opening marker through content to closing marker. 129 - /// When cursor is anywhere in this range, the syntax is visible. 130 - pub formatted_range: Option<Range<usize>>, 131 - } 132 - 133 - impl SyntaxSpanInfo { 134 - /// Adjust all position fields by a character delta. 135 - /// 136 - /// This adjusts both `char_range` and `formatted_range` (if present) together, 137 - /// ensuring they stay in sync. Use this instead of manually adjusting fields 138 - /// to avoid forgetting one. 139 - pub fn adjust_positions(&mut self, char_delta: isize) { 140 - self.char_range.start = (self.char_range.start as isize + char_delta) as usize; 141 - self.char_range.end = (self.char_range.end as isize + char_delta) as usize; 142 - if let Some(ref mut fr) = self.formatted_range { 143 - fr.start = (fr.start as isize + char_delta) as usize; 144 - fr.end = (fr.end as isize + char_delta) as usize; 145 - } 146 - } 147 - } 148 - 149 - /// Classify syntax text as inline or block level 150 - fn classify_syntax(text: &str) -> SyntaxType { 151 - let trimmed = text.trim_start(); 152 - 153 - // Check for block-level markers 154 - if trimmed.starts_with('#') 155 - || trimmed.starts_with('>') 156 - || trimmed.starts_with("```") 157 - || trimmed.starts_with("---") 158 - || (trimmed.starts_with('-') 159 - && trimmed 160 - .chars() 161 - .nth(1) 162 - .map(|c| c.is_whitespace()) 163 - .unwrap_or(false)) 164 - || (trimmed.starts_with('*') 165 - && trimmed 166 - .chars() 167 - .nth(1) 168 - .map(|c| c.is_whitespace()) 169 - .unwrap_or(false)) 170 - || trimmed 171 - .chars() 172 - .next() 173 - .map(|c| c.is_ascii_digit()) 174 - .unwrap_or(false) 175 - && trimmed.contains('.') 176 - { 177 - SyntaxType::Block 178 - } else { 179 - SyntaxType::Inline 180 - } 181 - } 182 - 183 - /// Synchronous callback for injecting embed content 184 - /// 185 - /// Takes the embed tag and returns optional HTML content to inject. 186 - pub trait EmbedContentProvider { 187 - fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>; 188 - } 189 - 190 - impl EmbedContentProvider for () { 191 - fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> { 192 - None 193 - } 194 - } 195 - 196 - impl EmbedContentProvider for &ResolvedContent { 197 - fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 198 - if let Tag::Embed { dest_url, .. } = tag { 199 - let url = dest_url.as_ref(); 200 - if url.starts_with("at://") { 201 - if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { 202 - return ResolvedContent::get_embed_content(self, &at_uri) 203 - .map(|s| s.to_string()); 204 - } 205 - } 206 - } 207 - None 208 - } 209 - } 210 - 211 - /// Resolves image URLs to CDN URLs based on stored images. 212 - /// 213 - /// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). 214 - /// This trait maps those names to the actual CDN URL using the blob CID and owner DID. 215 - pub trait ImageResolver { 216 - /// Resolve an image URL from markdown to a CDN URL. 217 - /// 218 - /// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL. 219 - fn resolve_image_url(&self, url: &str) -> Option<String>; 220 - } 221 - 222 - impl ImageResolver for () { 223 - fn resolve_image_url(&self, _url: &str) -> Option<String> { 224 - None 225 - } 226 - } 227 - 228 - /// Concrete image resolver that maps image names to URLs. 229 - /// 230 - /// Resolved image path type 231 - #[derive(Clone, Debug)] 232 - enum ResolvedImage { 233 - /// Data URL for immediate preview (still uploading) 234 - Pending(String), 235 - /// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}` 236 - Draft { 237 - blob_rkey: Rkey<'static>, 238 - ident: AtIdentifier<'static>, 239 - }, 240 - /// Published image: `/image/{ident}/{entry_rkey}/{name}` 241 - Published { 242 - entry_rkey: Rkey<'static>, 243 - ident: AtIdentifier<'static>, 244 - }, 245 - } 246 - 247 - /// Resolves image paths in the editor. 248 - /// 249 - /// Supports three states for images: 250 - /// - Pending: uses data URL for immediate preview while upload is in progress 251 - /// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}` 252 - /// - Published: uses path format `/image/{did}/{entry_rkey}/{name}` 253 - /// 254 - /// Image URLs in markdown use the format `/image/{name}`. 255 - #[derive(Clone, Default)] 256 - pub struct EditorImageResolver { 257 - /// All resolved images: name -> resolved path info 258 - images: std::collections::HashMap<String, ResolvedImage>, 259 - } 260 - 261 - impl EditorImageResolver { 262 - pub fn new() -> Self { 263 - Self::default() 264 - } 265 - 266 - /// Add a pending image with a data URL for immediate preview. 267 - pub fn add_pending(&mut self, name: String, data_url: String) { 268 - self.images.insert(name, ResolvedImage::Pending(data_url)); 269 - } 270 - 271 - /// Promote a pending image to uploaded (draft) status. 272 - pub fn promote_to_uploaded( 273 - &mut self, 274 - name: &str, 275 - blob_rkey: Rkey<'static>, 276 - ident: AtIdentifier<'static>, 277 - ) { 278 - self.images 279 - .insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident }); 280 - } 281 - 282 - /// Add an already-uploaded draft image. 283 - pub fn add_uploaded( 284 - &mut self, 285 - name: String, 286 - blob_rkey: Rkey<'static>, 287 - ident: AtIdentifier<'static>, 288 - ) { 289 - self.images 290 - .insert(name, ResolvedImage::Draft { blob_rkey, ident }); 291 - } 292 - 293 - /// Add a published image. 294 - pub fn add_published( 295 - &mut self, 296 - name: String, 297 - entry_rkey: Rkey<'static>, 298 - ident: AtIdentifier<'static>, 299 - ) { 300 - self.images 301 - .insert(name, ResolvedImage::Published { entry_rkey, ident }); 302 - } 303 - 304 - /// Check if an image is pending upload. 305 - pub fn is_pending(&self, name: &str) -> bool { 306 - matches!(self.images.get(name), Some(ResolvedImage::Pending(_))) 307 - } 308 - 309 - /// Build a resolver from editor images and user identifier. 310 - /// 311 - /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included. 312 - /// For published mode (entry_rkey=Some), all images are included. 313 - pub fn from_images<'a>( 314 - images: impl IntoIterator<Item = &'a super::document::EditorImage>, 315 - ident: AtIdentifier<'static>, 316 - entry_rkey: Option<Rkey<'static>>, 317 - ) -> Self { 318 - use jacquard::IntoStatic; 319 - 320 - let mut resolver = Self::new(); 321 - for editor_image in images { 322 - // Get the name from the Image (use alt text as fallback if name is empty) 323 - let name = editor_image 324 - .image 325 - .name 326 - .as_ref() 327 - .map(|n| n.to_string()) 328 - .unwrap_or_else(|| editor_image.image.alt.to_string()); 329 - 330 - if name.is_empty() { 331 - continue; 332 - } 333 - 334 - match &entry_rkey { 335 - // Published mode: use entry rkey for all images 336 - Some(rkey) => { 337 - resolver.add_published(name, rkey.clone(), ident.clone()); 338 - } 339 - // Draft mode: use published_blob_uri rkey 340 - None => { 341 - let blob_rkey = match &editor_image.published_blob_uri { 342 - Some(uri) => match uri.rkey() { 343 - Some(rkey) => rkey.0.clone().into_static(), 344 - None => continue, 345 - }, 346 - None => continue, 347 - }; 348 - resolver.add_uploaded(name, blob_rkey, ident.clone()); 349 - } 350 - } 351 - } 352 - resolver 353 - } 354 - } 355 - 356 - impl ImageResolver for EditorImageResolver { 357 - fn resolve_image_url(&self, url: &str) -> Option<String> { 358 - // Extract image name from /image/{name} format 359 - let name = url.strip_prefix("/image/").unwrap_or(url); 360 - 361 - let resolved = self.images.get(name)?; 362 - match resolved { 363 - ResolvedImage::Pending(data_url) => Some(data_url.clone()), 364 - ResolvedImage::Draft { blob_rkey, ident } => { 365 - Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name)) 366 - } 367 - ResolvedImage::Published { entry_rkey, ident } => { 368 - Some(format!("/image/{}/{}/{}", ident, entry_rkey, name)) 369 - } 370 - } 371 - } 372 - } 373 - 374 - impl ImageResolver for &EditorImageResolver { 375 - fn resolve_image_url(&self, url: &str) -> Option<String> { 376 - (*self).resolve_image_url(url) 377 - } 378 - } 379 - 380 48 /// Tracks the type of wrapper element emitted for WeaverBlock prefix 381 49 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 382 50 enum WrapperElement { 383 51 Aside, 384 52 Div, 53 + } 54 + 55 + #[derive(Debug, Clone, Copy)] 56 + pub enum TableState { 57 + Head, 58 + Body, 385 59 } 386 60 387 61 /// HTML writer that preserves markdown formatting characters. ··· 476 150 current_footnote_def: Option<(String, usize, usize)>, 477 151 478 152 _phantom: std::marker::PhantomData<&'a ()>, 479 - } 480 - 481 - #[derive(Debug, Clone, Copy)] 482 - enum TableState { 483 - Head, 484 - Body, 485 153 } 486 154 487 155 impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> ··· 756 424 } 757 425 } 758 426 759 - /// Emit syntax span for a given range and record offset mapping 760 - fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), fmt::Error> { 761 - if range.start < range.end { 762 - let syntax = &self.source[range.clone()]; 763 - if !syntax.is_empty() { 764 - let char_start = self.last_char_offset; 765 - let syntax_char_len = syntax.chars().count(); 766 - let char_end = char_start + syntax_char_len; 767 - 768 - tracing::trace!( 769 - target: "weaver::writer", 770 - byte_range = ?range, 771 - char_range = ?(char_start..char_end), 772 - syntax = %syntax.escape_debug(), 773 - "emit_syntax" 774 - ); 775 - 776 - // Whitespace-only content (trailing spaces, newlines) should be emitted 777 - // as plain text, not wrapped in a hideable syntax span 778 - let is_whitespace_only = syntax.trim().is_empty(); 779 - 780 - if is_whitespace_only { 781 - // Emit as plain text with tracking span (not hideable) 782 - let created_node = if self.current_node_id.is_none() { 783 - let node_id = self.gen_node_id(); 784 - write!(&mut self.writer, "<span id=\"{}\">", node_id)?; 785 - self.begin_node(node_id); 786 - true 787 - } else { 788 - false 789 - }; 790 - 791 - escape_html(&mut self.writer, syntax)?; 792 - 793 - // Record offset mapping BEFORE end_node (which clears current_node_id) 794 - self.record_mapping(range.clone(), char_start..char_end); 795 - self.last_char_offset = char_end; 796 - self.last_byte_offset = range.end; 797 - 798 - if created_node { 799 - self.write("</span>")?; 800 - self.end_node(); 801 - } 802 - } else { 803 - // Real syntax - wrap in hideable span 804 - let syntax_type = classify_syntax(syntax); 805 - let class = match syntax_type { 806 - SyntaxType::Inline => "md-syntax-inline", 807 - SyntaxType::Block => "md-syntax-block", 808 - }; 809 - 810 - // Generate unique ID for this syntax span 811 - let syn_id = self.gen_syn_id(); 812 - 813 - // If we're outside any node, create a wrapper span for tracking 814 - let created_node = if self.current_node_id.is_none() { 815 - let node_id = self.gen_node_id(); 816 - write!( 817 - &mut self.writer, 818 - "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 819 - node_id, class, syn_id, char_start, char_end 820 - )?; 821 - self.begin_node(node_id); 822 - true 823 - } else { 824 - write!( 825 - &mut self.writer, 826 - "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 827 - class, syn_id, char_start, char_end 828 - )?; 829 - false 830 - }; 831 - 832 - escape_html(&mut self.writer, syntax)?; 833 - self.write("</span>")?; 834 - 835 - // Record syntax span info for visibility toggling 836 - self.syntax_spans.push(SyntaxSpanInfo { 837 - syn_id, 838 - char_range: char_start..char_end, 839 - syntax_type, 840 - formatted_range: None, 841 - }); 842 - 843 - // Record offset mapping for this syntax 844 - self.record_mapping(range.clone(), char_start..char_end); 845 - self.last_char_offset = char_end; 846 - self.last_byte_offset = range.end; 847 - 848 - // Close wrapper if we created one 849 - if created_node { 850 - self.write("</span>")?; 851 - self.end_node(); 852 - } 853 - } 854 - } 855 - } 856 - Ok(()) 857 - } 858 - 859 - /// Emit syntax span inside current node with full offset tracking. 860 - /// 861 - /// Use this for syntax markers that appear inside block elements (headings, lists, 862 - /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates 863 - /// wrapper nodes, this assumes we're already inside a tracked node. 864 - /// 865 - /// - Writes `<span class="md-syntax-{class}">{syntax}</span>` 866 - /// - Records offset mapping (for cursor positioning) 867 - /// - Updates both `last_char_offset` and `last_byte_offset` 868 - fn emit_inner_syntax( 869 - &mut self, 870 - syntax: &str, 871 - byte_start: usize, 872 - syntax_type: SyntaxType, 873 - ) -> Result<(), fmt::Error> { 874 - if syntax.is_empty() { 875 - return Ok(()); 876 - } 877 - 878 - let char_start = self.last_char_offset; 879 - let syntax_char_len = syntax.chars().count(); 880 - let char_end = char_start + syntax_char_len; 881 - let byte_end = byte_start + syntax.len(); 882 - 883 - let class_str = match syntax_type { 884 - SyntaxType::Inline => "md-syntax-inline", 885 - SyntaxType::Block => "md-syntax-block", 886 - }; 887 - 888 - // Generate unique ID for this syntax span 889 - let syn_id = self.gen_syn_id(); 890 - 891 - write!( 892 - &mut self.writer, 893 - "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 894 - class_str, syn_id, char_start, char_end 895 - )?; 896 - escape_html(&mut self.writer, syntax)?; 897 - self.write("</span>")?; 898 - 899 - // Record syntax span info for visibility toggling 900 - self.syntax_spans.push(SyntaxSpanInfo { 901 - syn_id, 902 - char_range: char_start..char_end, 903 - syntax_type, 904 - formatted_range: None, 905 - }); 906 - 907 - // Record offset mapping for cursor positioning 908 - self.record_mapping(byte_start..byte_end, char_start..char_end); 909 - 910 - self.last_char_offset = char_end; 911 - self.last_byte_offset = byte_end; 912 - 913 - Ok(()) 914 - } 915 - 916 - /// Emit any gap between last position and next offset 917 - fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> { 918 - // Skip gap emission if we're inside a table being rendered as markdown 919 - if self.table_start_offset.is_some() && self.render_tables_as_markdown { 920 - return Ok(()); 921 - } 922 - 923 - // Skip gap emission if we're buffering code block content 924 - // The code block handler manages its own syntax emission 925 - if self.code_buffer.is_some() { 926 - return Ok(()); 927 - } 928 - 929 - if next_offset > self.last_byte_offset { 930 - self.emit_syntax(self.last_byte_offset..next_offset)?; 931 - } 932 - Ok(()) 933 - } 934 - 935 427 /// Generate a unique node ID. 936 428 /// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`. 937 429 /// Otherwise produces `n{counter}` for backwards compatibility. ··· 1226 718 let key = key.trim(); 1227 719 let value = value.trim(); 1228 720 if !key.is_empty() && !value.is_empty() { 1229 - attrs.push((CowStr::from(key.to_string()), CowStr::from(value.to_string()))); 721 + attrs.push(( 722 + CowStr::from(key.to_string()), 723 + CowStr::from(value.to_string()), 724 + )); 1230 725 } 1231 726 } else { 1232 727 // No colon - treat as class, strip leading dot if present ··· 1852 1347 self.weaver_block_buffer.push_str(&text); 1853 1348 } 1854 1349 } 1855 - Ok(()) 1856 - } 1857 - 1858 - fn start_tag(&mut self, tag: Tag<'_>, range: Range<usize>) -> Result<(), fmt::Error> { 1859 - // Check if this is a block-level tag that should have syntax inside 1860 - let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_)); 1861 - 1862 - // For inline tags, emit syntax before tag 1863 - if !is_block_tag && range.start < range.end { 1864 - let raw_text = &self.source[range.clone()]; 1865 - let opening_syntax = match &tag { 1866 - Tag::Strong => { 1867 - if raw_text.starts_with("**") { 1868 - Some("**") 1869 - } else if raw_text.starts_with("__") { 1870 - Some("__") 1871 - } else { 1872 - None 1873 - } 1874 - } 1875 - Tag::Emphasis => { 1876 - if raw_text.starts_with("*") { 1877 - Some("*") 1878 - } else if raw_text.starts_with("_") { 1879 - Some("_") 1880 - } else { 1881 - None 1882 - } 1883 - } 1884 - Tag::Strikethrough => { 1885 - if raw_text.starts_with("~~") { 1886 - Some("~~") 1887 - } else { 1888 - None 1889 - } 1890 - } 1891 - Tag::Link { link_type, .. } => { 1892 - if matches!(link_type, LinkType::WikiLink { .. }) { 1893 - if raw_text.starts_with("[[") { 1894 - Some("[[") 1895 - } else { 1896 - None 1897 - } 1898 - } else if raw_text.starts_with('[') { 1899 - Some("[") 1900 - } else { 1901 - None 1902 - } 1903 - } 1904 - // Note: Tag::Image and Tag::Embed handle their own syntax spans 1905 - // in their respective handlers, so don't emit here 1906 - _ => None, 1907 - }; 1908 - 1909 - if let Some(syntax) = opening_syntax { 1910 - let syntax_type = classify_syntax(syntax); 1911 - let class = match syntax_type { 1912 - SyntaxType::Inline => "md-syntax-inline", 1913 - SyntaxType::Block => "md-syntax-block", 1914 - }; 1915 - 1916 - let char_start = self.last_char_offset; 1917 - let syntax_char_len = syntax.chars().count(); 1918 - let char_end = char_start + syntax_char_len; 1919 - let syntax_byte_len = syntax.len(); 1920 - 1921 - // Generate unique ID for this syntax span 1922 - let syn_id = self.gen_syn_id(); 1923 - 1924 - write!( 1925 - &mut self.writer, 1926 - "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 1927 - class, syn_id, char_start, char_end 1928 - )?; 1929 - escape_html(&mut self.writer, syntax)?; 1930 - self.write("</span>")?; 1931 - 1932 - // Record syntax span info for visibility toggling 1933 - self.syntax_spans.push(SyntaxSpanInfo { 1934 - syn_id: syn_id.clone(), 1935 - char_range: char_start..char_end, 1936 - syntax_type, 1937 - formatted_range: None, // Will be updated when closing tag is emitted 1938 - }); 1939 - 1940 - // Record offset mapping for cursor positioning 1941 - // This is critical - without it, current_node_char_offset is wrong 1942 - // and all subsequent cursor positions are shifted 1943 - let byte_start = range.start; 1944 - let byte_end = range.start + syntax_byte_len; 1945 - self.record_mapping(byte_start..byte_end, char_start..char_end); 1946 - 1947 - // For paired inline syntax, track opening span for formatted_range 1948 - if matches!( 1949 - tag, 1950 - Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } 1951 - ) { 1952 - self.pending_inline_formats.push((syn_id, char_start)); 1953 - } 1954 - 1955 - // Update tracking - we've consumed this opening syntax 1956 - self.last_char_offset = char_end; 1957 - self.last_byte_offset = range.start + syntax_byte_len; 1958 - } 1959 - } 1960 - 1961 - // Emit the opening tag 1962 - match tag { 1963 - // HTML blocks get their own paragraph to try and corral them better 1964 - Tag::HtmlBlock => { 1965 - // Record paragraph start for boundary tracking 1966 - // BUT skip if inside a list - list owns the paragraph boundary 1967 - if self.list_depth == 0 { 1968 - self.current_paragraph_start = 1969 - Some((self.last_byte_offset, self.last_char_offset)); 1970 - } 1971 - let node_id = self.gen_node_id(); 1972 - 1973 - if self.end_newline { 1974 - write!( 1975 - &mut self.writer, 1976 - r#"<p id="{}" class="html-embed html-embed-block">"#, 1977 - node_id 1978 - )?; 1979 - } else { 1980 - write!( 1981 - &mut self.writer, 1982 - r#"\n<p id="{}" class="html-embed html-embed-block">"#, 1983 - node_id 1984 - )?; 1985 - } 1986 - self.begin_node(node_id.clone()); 1987 - 1988 - // Map the start position of the paragraph (before any content) 1989 - // This allows cursor to be placed at the very beginning 1990 - let para_start_char = self.last_char_offset; 1991 - let mapping = OffsetMapping { 1992 - byte_range: range.start..range.start, 1993 - char_range: para_start_char..para_start_char, 1994 - node_id, 1995 - char_offset_in_node: 0, 1996 - child_index: Some(0), // position before first child 1997 - utf16_len: 0, 1998 - }; 1999 - self.offset_maps.push(mapping); 2000 - 2001 - Ok(()) 2002 - } 2003 - Tag::Paragraph(_) => { 2004 - // Handle wrapper before block 2005 - self.emit_wrapper_start()?; 2006 - 2007 - // Record paragraph start for boundary tracking 2008 - // BUT skip if inside a list - list owns the paragraph boundary 2009 - if self.list_depth == 0 { 2010 - self.current_paragraph_start = 2011 - Some((self.last_byte_offset, self.last_char_offset)); 2012 - } 2013 - 2014 - let node_id = self.gen_node_id(); 2015 - if self.end_newline { 2016 - write!(&mut self.writer, "<p id=\"{}\">", node_id)?; 2017 - } else { 2018 - write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?; 2019 - } 2020 - self.begin_node(node_id.clone()); 2021 - 2022 - // Map the start position of the paragraph (before any content) 2023 - // This allows cursor to be placed at the very beginning 2024 - let para_start_char = self.last_char_offset; 2025 - let mapping = OffsetMapping { 2026 - byte_range: range.start..range.start, 2027 - char_range: para_start_char..para_start_char, 2028 - node_id, 2029 - char_offset_in_node: 0, 2030 - child_index: Some(0), // position before first child 2031 - utf16_len: 0, 2032 - }; 2033 - self.offset_maps.push(mapping); 2034 - 2035 - // Emit > syntax if we're inside a blockquote 2036 - if let Some(bq_range) = self.pending_blockquote_range.take() { 2037 - if bq_range.start < bq_range.end { 2038 - let raw_text = &self.source[bq_range.clone()]; 2039 - if let Some(gt_pos) = raw_text.find('>') { 2040 - // Extract > [!NOTE] or just > 2041 - let after_gt = &raw_text[gt_pos + 1..]; 2042 - let syntax_end = if after_gt.trim_start().starts_with("[!") { 2043 - // Find the closing ] 2044 - if let Some(close_bracket) = after_gt.find(']') { 2045 - gt_pos + 1 + close_bracket + 1 2046 - } else { 2047 - gt_pos + 1 2048 - } 2049 - } else { 2050 - // Just > and maybe a space 2051 - (gt_pos + 1).min(raw_text.len()) 2052 - }; 2053 - 2054 - let syntax = &raw_text[gt_pos..syntax_end]; 2055 - let syntax_byte_start = bq_range.start + gt_pos; 2056 - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; 2057 - } 2058 - } 2059 - } 2060 - Ok(()) 2061 - } 2062 - Tag::Heading { 2063 - level, 2064 - id, 2065 - classes, 2066 - attrs, 2067 - } => { 2068 - // Emit wrapper if pending (but don't close on heading end - wraps following block too) 2069 - self.emit_wrapper_start()?; 2070 - 2071 - // Record paragraph start for boundary tracking 2072 - // Treat headings as paragraph-level blocks 2073 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2074 - 2075 - if !self.end_newline { 2076 - self.write("\n")?; 2077 - } 2078 - 2079 - // Generate node ID for offset tracking 2080 - let node_id = self.gen_node_id(); 2081 - 2082 - self.write("<")?; 2083 - write!(&mut self.writer, "{}", level)?; 2084 - 2085 - // Add our tracking ID as data attribute (preserve user's id if present) 2086 - self.write(" data-node-id=\"")?; 2087 - self.write(&node_id)?; 2088 - self.write("\"")?; 2089 - 2090 - if let Some(id) = id { 2091 - self.write(" id=\"")?; 2092 - escape_html(&mut self.writer, &id)?; 2093 - self.write("\"")?; 2094 - } 2095 - if !classes.is_empty() { 2096 - self.write(" class=\"")?; 2097 - for (i, class) in classes.iter().enumerate() { 2098 - if i > 0 { 2099 - self.write(" ")?; 2100 - } 2101 - escape_html(&mut self.writer, class)?; 2102 - } 2103 - self.write("\"")?; 2104 - } 2105 - for (attr, value) in attrs { 2106 - self.write(" ")?; 2107 - escape_html(&mut self.writer, &attr)?; 2108 - if let Some(val) = value { 2109 - self.write("=\"")?; 2110 - escape_html(&mut self.writer, &val)?; 2111 - self.write("\"")?; 2112 - } else { 2113 - self.write("=\"\"")?; 2114 - } 2115 - } 2116 - self.write(">")?; 2117 - 2118 - // Begin node tracking for offset mapping 2119 - self.begin_node(node_id.clone()); 2120 - 2121 - // Map the start position of the heading (before any content) 2122 - // This allows cursor to be placed at the very beginning 2123 - let heading_start_char = self.last_char_offset; 2124 - let mapping = OffsetMapping { 2125 - byte_range: range.start..range.start, 2126 - char_range: heading_start_char..heading_start_char, 2127 - node_id: node_id.clone(), 2128 - char_offset_in_node: 0, 2129 - child_index: Some(0), // position before first child 2130 - utf16_len: 0, 2131 - }; 2132 - self.offset_maps.push(mapping); 2133 - 2134 - // Emit # syntax inside the heading tag 2135 - if range.start < range.end { 2136 - let raw_text = &self.source[range.clone()]; 2137 - let count = level as usize; 2138 - let pattern = "#".repeat(count); 2139 - 2140 - // Find where the # actually starts (might have leading whitespace) 2141 - if let Some(hash_pos) = raw_text.find(&pattern) { 2142 - // Extract "# " or "## " etc 2143 - let syntax_end = (hash_pos + count + 1).min(raw_text.len()); 2144 - let syntax = &raw_text[hash_pos..syntax_end]; 2145 - let syntax_byte_start = range.start + hash_pos; 2146 - 2147 - self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; 2148 - } 2149 - } 2150 - Ok(()) 2151 - } 2152 - Tag::Table(alignments) => { 2153 - if self.render_tables_as_markdown { 2154 - // Store start offset and skip HTML rendering 2155 - self.table_start_offset = Some(range.start); 2156 - self.in_non_writing_block = true; // Suppress content output 2157 - Ok(()) 2158 - } else { 2159 - self.emit_wrapper_start()?; 2160 - self.table_alignments = alignments; 2161 - self.write("<table>") 2162 - } 2163 - } 2164 - Tag::TableHead => { 2165 - if self.render_tables_as_markdown { 2166 - Ok(()) // Skip HTML rendering 2167 - } else { 2168 - self.table_state = TableState::Head; 2169 - self.table_cell_index = 0; 2170 - self.write("<thead><tr>") 2171 - } 2172 - } 2173 - Tag::TableRow => { 2174 - if self.render_tables_as_markdown { 2175 - Ok(()) // Skip HTML rendering 2176 - } else { 2177 - self.table_cell_index = 0; 2178 - self.write("<tr>") 2179 - } 2180 - } 2181 - Tag::TableCell => { 2182 - if self.render_tables_as_markdown { 2183 - Ok(()) // Skip HTML rendering 2184 - } else { 2185 - match self.table_state { 2186 - TableState::Head => self.write("<th")?, 2187 - TableState::Body => self.write("<td")?, 2188 - } 2189 - match self.table_alignments.get(self.table_cell_index) { 2190 - Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"), 2191 - Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), 2192 - Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), 2193 - _ => self.write(">"), 2194 - } 2195 - } 2196 - } 2197 - Tag::BlockQuote(kind) => { 2198 - self.emit_wrapper_start()?; 2199 - 2200 - let class_str = match kind { 2201 - None => "", 2202 - Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", 2203 - Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"", 2204 - Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"", 2205 - Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"", 2206 - Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"", 2207 - }; 2208 - if self.end_newline { 2209 - write!(&mut self.writer, "<blockquote{}>\n", class_str)?; 2210 - } else { 2211 - write!(&mut self.writer, "\n<blockquote{}>\n", class_str)?; 2212 - } 2213 - 2214 - // Store range for emitting > inside the next paragraph 2215 - self.pending_blockquote_range = Some(range); 2216 - Ok(()) 2217 - } 2218 - Tag::CodeBlock(info) => { 2219 - self.emit_wrapper_start()?; 2220 - 2221 - // Track code block as paragraph-level block 2222 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2223 - 2224 - if !self.end_newline { 2225 - self.write_newline()?; 2226 - } 2227 - 2228 - // Generate node ID for code block 2229 - let node_id = self.gen_node_id(); 2230 - 2231 - match info { 2232 - CodeBlockKind::Fenced(info) => { 2233 - // Emit opening ```language and track both char and byte offsets 2234 - if range.start < range.end { 2235 - let raw_text = &self.source[range.clone()]; 2236 - if let Some(fence_pos) = raw_text.find("```") { 2237 - let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); 2238 - let syntax = &raw_text[fence_pos..fence_end]; 2239 - let syntax_char_len = syntax.chars().count() + 1; // +1 for newline 2240 - let syntax_byte_len = syntax.len() + 1; // +1 for newline 2241 - 2242 - let syn_id = self.gen_syn_id(); 2243 - let char_start = self.last_char_offset; 2244 - let char_end = char_start + syntax_char_len; 2245 - 2246 - write!( 2247 - &mut self.writer, 2248 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 2249 - syn_id, char_start, char_end 2250 - )?; 2251 - escape_html(&mut self.writer, syntax)?; 2252 - self.write("</span>\n")?; 2253 - 2254 - // Track opening span index for formatted_range update later 2255 - self.code_block_opening_span_idx = Some(self.syntax_spans.len()); 2256 - self.code_block_char_start = Some(char_start); 2257 - 2258 - self.syntax_spans.push(SyntaxSpanInfo { 2259 - syn_id, 2260 - char_range: char_start..char_end, 2261 - syntax_type: SyntaxType::Block, 2262 - formatted_range: None, // Will be set in TagEnd::CodeBlock 2263 - }); 2264 - 2265 - self.last_char_offset += syntax_char_len; 2266 - self.last_byte_offset = range.start + fence_pos + syntax_byte_len; 2267 - } 2268 - } 2269 - 2270 - let lang = info.split(' ').next().unwrap(); 2271 - let lang_opt = if lang.is_empty() { 2272 - None 2273 - } else { 2274 - Some(lang.to_string()) 2275 - }; 2276 - // Start buffering 2277 - self.code_buffer = Some((lang_opt, String::new())); 2278 - 2279 - // Begin node tracking for offset mapping 2280 - self.begin_node(node_id); 2281 - Ok(()) 2282 - } 2283 - CodeBlockKind::Indented => { 2284 - // Ignore indented code blocks (as per executive decision) 2285 - self.code_buffer = Some((None, String::new())); 2286 - 2287 - // Begin node tracking for offset mapping 2288 - self.begin_node(node_id); 2289 - Ok(()) 2290 - } 2291 - } 2292 - } 2293 - Tag::List(Some(1)) => { 2294 - self.emit_wrapper_start()?; 2295 - // Track list as paragraph-level block 2296 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2297 - self.list_depth += 1; 2298 - if self.end_newline { 2299 - self.write("<ol>\n") 2300 - } else { 2301 - self.write("\n<ol>\n") 2302 - } 2303 - } 2304 - Tag::List(Some(start)) => { 2305 - self.emit_wrapper_start()?; 2306 - // Track list as paragraph-level block 2307 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2308 - self.list_depth += 1; 2309 - if self.end_newline { 2310 - self.write("<ol start=\"")?; 2311 - } else { 2312 - self.write("\n<ol start=\"")?; 2313 - } 2314 - write!(&mut self.writer, "{}", start)?; 2315 - self.write("\">\n") 2316 - } 2317 - Tag::List(None) => { 2318 - self.emit_wrapper_start()?; 2319 - // Track list as paragraph-level block 2320 - self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 2321 - self.list_depth += 1; 2322 - if self.end_newline { 2323 - self.write("<ul>\n") 2324 - } else { 2325 - self.write("\n<ul>\n") 2326 - } 2327 - } 2328 - Tag::Item => { 2329 - // Generate node ID for list item 2330 - let node_id = self.gen_node_id(); 2331 - 2332 - if self.end_newline { 2333 - write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?; 2334 - } else { 2335 - write!(&mut self.writer, "\n<li data-node-id=\"{}\">", node_id)?; 2336 - } 2337 - 2338 - // Begin node tracking 2339 - self.begin_node(node_id); 2340 - 2341 - // Emit list marker syntax inside the <li> tag and track both offsets 2342 - if range.start < range.end { 2343 - let raw_text = &self.source[range.clone()]; 2344 - 2345 - // Try to find the list marker (-, *, or digit.) 2346 - let trimmed = raw_text.trim_start(); 2347 - let leading_ws_bytes = raw_text.len() - trimmed.len(); 2348 - let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count(); 2349 - 2350 - if let Some(marker) = trimmed.chars().next() { 2351 - if marker == '-' || marker == '*' { 2352 - // Unordered list: extract "- " or "* " 2353 - let marker_end = trimmed 2354 - .find(|c: char| c != '-' && c != '*') 2355 - .map(|pos| pos + 1) 2356 - .unwrap_or(1); 2357 - let syntax = &trimmed[..marker_end.min(trimmed.len())]; 2358 - let char_start = self.last_char_offset; 2359 - let syntax_char_len = leading_ws_chars + syntax.chars().count(); 2360 - let syntax_byte_len = leading_ws_bytes + syntax.len(); 2361 - let char_end = char_start + syntax_char_len; 2362 - 2363 - let syn_id = self.gen_syn_id(); 2364 - write!( 2365 - &mut self.writer, 2366 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 2367 - syn_id, char_start, char_end 2368 - )?; 2369 - escape_html(&mut self.writer, syntax)?; 2370 - self.write("</span>")?; 2371 - 2372 - self.syntax_spans.push(SyntaxSpanInfo { 2373 - syn_id, 2374 - char_range: char_start..char_end, 2375 - syntax_type: SyntaxType::Block, 2376 - formatted_range: None, 2377 - }); 2378 - 2379 - // Record offset mapping for cursor positioning 2380 - self.record_mapping( 2381 - range.start..range.start + syntax_byte_len, 2382 - char_start..char_end, 2383 - ); 2384 - self.last_char_offset = char_end; 2385 - self.last_byte_offset = range.start + syntax_byte_len; 2386 - } else if marker.is_ascii_digit() { 2387 - // Ordered list: extract "1. " or similar (including trailing space) 2388 - if let Some(dot_pos) = trimmed.find('.') { 2389 - let syntax_end = (dot_pos + 2).min(trimmed.len()); 2390 - let syntax = &trimmed[..syntax_end]; 2391 - let char_start = self.last_char_offset; 2392 - let syntax_char_len = leading_ws_chars + syntax.chars().count(); 2393 - let syntax_byte_len = leading_ws_bytes + syntax.len(); 2394 - let char_end = char_start + syntax_char_len; 2395 - 2396 - let syn_id = self.gen_syn_id(); 2397 - write!( 2398 - &mut self.writer, 2399 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 2400 - syn_id, char_start, char_end 2401 - )?; 2402 - escape_html(&mut self.writer, syntax)?; 2403 - self.write("</span>")?; 2404 - 2405 - self.syntax_spans.push(SyntaxSpanInfo { 2406 - syn_id, 2407 - char_range: char_start..char_end, 2408 - syntax_type: SyntaxType::Block, 2409 - formatted_range: None, 2410 - }); 2411 - 2412 - // Record offset mapping for cursor positioning 2413 - self.record_mapping( 2414 - range.start..range.start + syntax_byte_len, 2415 - char_start..char_end, 2416 - ); 2417 - self.last_char_offset = char_end; 2418 - self.last_byte_offset = range.start + syntax_byte_len; 2419 - } 2420 - } 2421 - } 2422 - } 2423 - Ok(()) 2424 - } 2425 - Tag::DefinitionList => { 2426 - self.emit_wrapper_start()?; 2427 - if self.end_newline { 2428 - self.write("<dl>\n") 2429 - } else { 2430 - self.write("\n<dl>\n") 2431 - } 2432 - } 2433 - Tag::DefinitionListTitle => { 2434 - let node_id = self.gen_node_id(); 2435 - 2436 - if self.end_newline { 2437 - write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?; 2438 - } else { 2439 - write!(&mut self.writer, "\n<dt data-node-id=\"{}\">", node_id)?; 2440 - } 2441 - 2442 - self.begin_node(node_id); 2443 - Ok(()) 2444 - } 2445 - Tag::DefinitionListDefinition => { 2446 - let node_id = self.gen_node_id(); 2447 - 2448 - if self.end_newline { 2449 - write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?; 2450 - } else { 2451 - write!(&mut self.writer, "\n<dd data-node-id=\"{}\">", node_id)?; 2452 - } 2453 - 2454 - self.begin_node(node_id); 2455 - Ok(()) 2456 - } 2457 - Tag::Subscript => self.write("<sub>"), 2458 - Tag::Superscript => self.write("<sup>"), 2459 - Tag::Emphasis => self.write("<em>"), 2460 - Tag::Strong => self.write("<strong>"), 2461 - Tag::Strikethrough => self.write("<s>"), 2462 - Tag::Link { 2463 - link_type: LinkType::Email, 2464 - dest_url, 2465 - title, 2466 - .. 2467 - } => { 2468 - self.write("<a href=\"mailto:")?; 2469 - escape_href(&mut self.writer, &dest_url)?; 2470 - if !title.is_empty() { 2471 - self.write("\" title=\"")?; 2472 - escape_html(&mut self.writer, &title)?; 2473 - } 2474 - self.write("\">") 2475 - } 2476 - Tag::Link { 2477 - link_type, 2478 - dest_url, 2479 - title, 2480 - .. 2481 - } => { 2482 - // Collect refs for later resolution 2483 - let url = dest_url.as_ref(); 2484 - if matches!(link_type, LinkType::WikiLink { .. }) { 2485 - let (target, fragment) = weaver_common::EntryIndex::parse_wikilink(url); 2486 - self.ref_collector.add_wikilink(target, fragment, None); 2487 - } else if url.starts_with("at://") { 2488 - self.ref_collector.add_at_link(url); 2489 - } 2490 - 2491 - // Determine link validity class for wikilinks 2492 - let validity_class = if matches!(link_type, LinkType::WikiLink { .. }) { 2493 - if let Some(index) = &self.entry_index { 2494 - if index.resolve(dest_url.as_ref()).is_some() { 2495 - " link-valid" 2496 - } else { 2497 - " link-broken" 2498 - } 2499 - } else { 2500 - "" 2501 - } 2502 - } else { 2503 - "" 2504 - }; 2505 - 2506 - self.write("<a class=\"link")?; 2507 - self.write(validity_class)?; 2508 - self.write("\" href=\"")?; 2509 - escape_href(&mut self.writer, &dest_url)?; 2510 - if !title.is_empty() { 2511 - self.write("\" title=\"")?; 2512 - escape_html(&mut self.writer, &title)?; 2513 - } 2514 - self.write("\">") 2515 - } 2516 - Tag::Image { 2517 - link_type, 2518 - dest_url, 2519 - title, 2520 - id, 2521 - attrs, 2522 - } => { 2523 - // Check if this is actually an AT embed disguised as a wikilink image 2524 - // (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type) 2525 - let url = dest_url.as_ref(); 2526 - if matches!(link_type, LinkType::WikiLink { .. }) 2527 - && (url.starts_with("at://") || url.starts_with("did:")) 2528 - { 2529 - return self.write_embed( 2530 - range, 2531 - EmbedType::Other, // AT embeds - disambiguated via NSID later 2532 - dest_url, 2533 - title, 2534 - id, 2535 - attrs, 2536 - ); 2537 - } 2538 - 2539 - // Image rendering: all syntax elements share one syn_id for visibility toggling 2540 - // Structure: ![ alt text ](url) <img> cursor-landing 2541 - let raw_text = &self.source[range.clone()]; 2542 - let syn_id = self.gen_syn_id(); 2543 - let opening_char_start = self.last_char_offset; 2544 - 2545 - // Find the alt text and closing syntax positions 2546 - let paren_pos = raw_text.rfind("](").unwrap_or(raw_text.len()); 2547 - let alt_text = if raw_text.starts_with("![") && paren_pos > 2 { 2548 - &raw_text[2..paren_pos] 2549 - } else { 2550 - "" 2551 - }; 2552 - let closing_syntax = if paren_pos < raw_text.len() { 2553 - &raw_text[paren_pos..] 2554 - } else { 2555 - "" 2556 - }; 2557 - 2558 - // Calculate char positions 2559 - let alt_char_len = alt_text.chars().count(); 2560 - let closing_char_len = closing_syntax.chars().count(); 2561 - let opening_char_end = opening_char_start + 2; // "![" 2562 - let alt_char_start = opening_char_end; 2563 - let alt_char_end = alt_char_start + alt_char_len; 2564 - let closing_char_start = alt_char_end; 2565 - let closing_char_end = closing_char_start + closing_char_len; 2566 - let formatted_range = opening_char_start..closing_char_end; 2567 - 2568 - // 1. Emit opening ![ syntax span 2569 - if raw_text.starts_with("![") { 2570 - write!( 2571 - &mut self.writer, 2572 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![</span>", 2573 - syn_id, opening_char_start, opening_char_end 2574 - )?; 2575 - 2576 - self.syntax_spans.push(SyntaxSpanInfo { 2577 - syn_id: syn_id.clone(), 2578 - char_range: opening_char_start..opening_char_end, 2579 - syntax_type: SyntaxType::Inline, 2580 - formatted_range: Some(formatted_range.clone()), 2581 - }); 2582 - 2583 - // Record offset mapping for ![ 2584 - self.record_mapping( 2585 - range.start..range.start + 2, 2586 - opening_char_start..opening_char_end, 2587 - ); 2588 - } 2589 - 2590 - // 2. Emit alt text span (same syn_id, editable when visible) 2591 - if !alt_text.is_empty() { 2592 - write!( 2593 - &mut self.writer, 2594 - "<span class=\"image-alt\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 2595 - syn_id, alt_char_start, alt_char_end 2596 - )?; 2597 - escape_html(&mut self.writer, alt_text)?; 2598 - self.write("</span>")?; 2599 - 2600 - self.syntax_spans.push(SyntaxSpanInfo { 2601 - syn_id: syn_id.clone(), 2602 - char_range: alt_char_start..alt_char_end, 2603 - syntax_type: SyntaxType::Inline, 2604 - formatted_range: Some(formatted_range.clone()), 2605 - }); 2606 - 2607 - // Record offset mapping for alt text 2608 - self.record_mapping( 2609 - range.start + 2..range.start + 2 + alt_text.len(), 2610 - alt_char_start..alt_char_end, 2611 - ); 2612 - } 2613 - 2614 - // 3. Emit closing ](url) syntax span 2615 - if !closing_syntax.is_empty() { 2616 - write!( 2617 - &mut self.writer, 2618 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 2619 - syn_id, closing_char_start, closing_char_end 2620 - )?; 2621 - escape_html(&mut self.writer, closing_syntax)?; 2622 - self.write("</span>")?; 2623 - 2624 - self.syntax_spans.push(SyntaxSpanInfo { 2625 - syn_id: syn_id.clone(), 2626 - char_range: closing_char_start..closing_char_end, 2627 - syntax_type: SyntaxType::Inline, 2628 - formatted_range: Some(formatted_range.clone()), 2629 - }); 2630 - 2631 - // Record offset mapping for ](url) 2632 - self.record_mapping( 2633 - range.start + paren_pos..range.end, 2634 - closing_char_start..closing_char_end, 2635 - ); 2636 - } 2637 - 2638 - // 4. Emit <img> element (no syn_id - always visible) 2639 - self.write("<img src=\"")?; 2640 - let resolved_url = self 2641 - .image_resolver 2642 - .as_ref() 2643 - .and_then(|r| r.resolve_image_url(&dest_url)); 2644 - if let Some(ref cdn_url) = resolved_url { 2645 - escape_href(&mut self.writer, cdn_url)?; 2646 - } else { 2647 - escape_href(&mut self.writer, &dest_url)?; 2648 - } 2649 - self.write("\" alt=\"")?; 2650 - escape_html(&mut self.writer, alt_text)?; 2651 - self.write("\"")?; 2652 - if !title.is_empty() { 2653 - self.write(" title=\"")?; 2654 - escape_html(&mut self.writer, &title)?; 2655 - self.write("\"")?; 2656 - } 2657 - if let Some(attrs) = attrs { 2658 - if !attrs.classes.is_empty() { 2659 - self.write(" class=\"")?; 2660 - for (i, class) in attrs.classes.iter().enumerate() { 2661 - if i > 0 { 2662 - self.write(" ")?; 2663 - } 2664 - escape_html(&mut self.writer, class)?; 2665 - } 2666 - self.write("\"")?; 2667 - } 2668 - for (attr, value) in &attrs.attrs { 2669 - self.write(" ")?; 2670 - escape_html(&mut self.writer, attr)?; 2671 - self.write("=\"")?; 2672 - escape_html(&mut self.writer, value)?; 2673 - self.write("\"")?; 2674 - } 2675 - } 2676 - self.write(" />")?; 2677 - 2678 - // Consume the text events for alt (they're still in the iterator) 2679 - // Use consume_until_end() since we already wrote alt text from source 2680 - self.consume_until_end(); 2681 - 2682 - // Update offsets 2683 - self.last_char_offset = closing_char_end; 2684 - self.last_byte_offset = range.end; 2685 - 2686 - Ok(()) 2687 - } 2688 - Tag::Embed { 2689 - embed_type, 2690 - dest_url, 2691 - title, 2692 - id, 2693 - attrs, 2694 - } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 2695 - Tag::WeaverBlock(_, attrs) => { 2696 - self.in_non_writing_block = true; 2697 - self.weaver_block_buffer.clear(); 2698 - self.weaver_block_char_start = Some(self.last_char_offset); 2699 - // Store attrs from Start tag, will merge with parsed text on End 2700 - if !attrs.classes.is_empty() || !attrs.attrs.is_empty() { 2701 - self.pending_block_attrs = Some(attrs.into_static()); 2702 - } 2703 - Ok(()) 2704 - } 2705 - Tag::FootnoteDefinition(name) => { 2706 - // Emit the [^name]: prefix as a hideable syntax span 2707 - // The source should have "[^name]: " at the start 2708 - let prefix = format!("[^{}]: ", name); 2709 - let char_start = self.last_char_offset; 2710 - let prefix_char_len = prefix.chars().count(); 2711 - let char_end = char_start + prefix_char_len; 2712 - let syn_id = self.gen_syn_id(); 2713 - 2714 - if !self.end_newline { 2715 - self.write("\n")?; 2716 - } 2717 - 2718 - write!( 2719 - &mut self.writer, 2720 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 2721 - syn_id, char_start, char_end 2722 - )?; 2723 - escape_html(&mut self.writer, &prefix)?; 2724 - self.write("</span>")?; 2725 - 2726 - // Track this span for linking with the footnote reference 2727 - let def_span_index = self.syntax_spans.len(); 2728 - self.syntax_spans.push(SyntaxSpanInfo { 2729 - syn_id, 2730 - char_range: char_start..char_end, 2731 - syntax_type: SyntaxType::Block, 2732 - formatted_range: None, // Set at FootnoteDefinition end 2733 - }); 2734 - 2735 - // Store the definition info for linking at end 2736 - self.current_footnote_def = Some((name.to_string(), def_span_index, char_start)); 2737 - 2738 - // Record offset mapping for the syntax span 2739 - self.record_mapping(range.start..range.start + prefix.len(), char_start..char_end); 2740 - 2741 - // Update tracking for the prefix 2742 - self.last_char_offset = char_end; 2743 - self.last_byte_offset = range.start + prefix.len(); 2744 - 2745 - // Emit the definition container 2746 - write!( 2747 - &mut self.writer, 2748 - "<div class=\"footnote-definition\" id=\"fn-{}\">", 2749 - name 2750 - )?; 2751 - 2752 - // Get/create footnote number for the label 2753 - let len = self.numbers.len() + 1; 2754 - let number = *self.numbers.entry(name.to_string()).or_insert(len); 2755 - write!( 2756 - &mut self.writer, 2757 - "<sup class=\"footnote-definition-label\">{}</sup>", 2758 - number 2759 - )?; 2760 - 2761 - Ok(()) 2762 - } 2763 - Tag::MetadataBlock(_) => { 2764 - self.in_non_writing_block = true; 2765 - Ok(()) 2766 - } 2767 - } 2768 - } 2769 - 2770 - fn end_tag( 2771 - &mut self, 2772 - tag: markdown_weaver::TagEnd, 2773 - range: Range<usize>, 2774 - ) -> Result<(), fmt::Error> { 2775 - use markdown_weaver::TagEnd; 2776 - 2777 - // Emit tag HTML first 2778 - let result = match tag { 2779 - TagEnd::HtmlBlock => { 2780 - // Capture paragraph boundary info BEFORE writing closing HTML 2781 - // Skip if inside a list - list owns the paragraph boundary 2782 - let para_boundary = if self.list_depth == 0 { 2783 - self.current_paragraph_start 2784 - .take() 2785 - .map(|(byte_start, char_start)| { 2786 - ( 2787 - byte_start..self.last_byte_offset, 2788 - char_start..self.last_char_offset, 2789 - ) 2790 - }) 2791 - } else { 2792 - None 2793 - }; 2794 - 2795 - // Write closing HTML to current segment 2796 - self.end_node(); 2797 - self.write("</p>\n")?; 2798 - 2799 - // Now finalize paragraph (starts new segment) 2800 - if let Some((byte_range, char_range)) = para_boundary { 2801 - self.finalize_paragraph(byte_range, char_range); 2802 - } 2803 - Ok(()) 2804 - } 2805 - TagEnd::Paragraph(_) => { 2806 - // Capture paragraph boundary info BEFORE writing closing HTML 2807 - // Skip if inside a list - list owns the paragraph boundary 2808 - let para_boundary = if self.list_depth == 0 { 2809 - self.current_paragraph_start 2810 - .take() 2811 - .map(|(byte_start, char_start)| { 2812 - ( 2813 - byte_start..self.last_byte_offset, 2814 - char_start..self.last_char_offset, 2815 - ) 2816 - }) 2817 - } else { 2818 - None 2819 - }; 2820 - 2821 - // Write closing HTML to current segment 2822 - self.end_node(); 2823 - self.write("</p>\n")?; 2824 - self.close_wrapper()?; 2825 - 2826 - // Now finalize paragraph (starts new segment) 2827 - if let Some((byte_range, char_range)) = para_boundary { 2828 - self.finalize_paragraph(byte_range, char_range); 2829 - } 2830 - Ok(()) 2831 - } 2832 - TagEnd::Heading(level) => { 2833 - // Capture paragraph boundary info BEFORE writing closing HTML 2834 - let para_boundary = 2835 - self.current_paragraph_start 2836 - .take() 2837 - .map(|(byte_start, char_start)| { 2838 - ( 2839 - byte_start..self.last_byte_offset, 2840 - char_start..self.last_char_offset, 2841 - ) 2842 - }); 2843 - 2844 - // Write closing HTML to current segment 2845 - self.end_node(); 2846 - self.write("</")?; 2847 - write!(&mut self.writer, "{}", level)?; 2848 - self.write(">\n")?; 2849 - // Note: Don't close wrapper here - headings typically go with following block 2850 - 2851 - // Now finalize paragraph (starts new segment) 2852 - if let Some((byte_range, char_range)) = para_boundary { 2853 - self.finalize_paragraph(byte_range, char_range); 2854 - } 2855 - Ok(()) 2856 - } 2857 - TagEnd::Table => { 2858 - if self.render_tables_as_markdown { 2859 - // Emit the raw markdown table 2860 - if let Some(start) = self.table_start_offset.take() { 2861 - let table_text = &self.source[start..range.end]; 2862 - self.in_non_writing_block = false; 2863 - 2864 - // Wrap in a pre or div for styling 2865 - self.write("<pre class=\"table-markdown\">")?; 2866 - escape_html(&mut self.writer, table_text)?; 2867 - self.write("</pre>\n")?; 2868 - } 2869 - Ok(()) 2870 - } else { 2871 - self.write("</tbody></table>\n") 2872 - } 2873 - } 2874 - TagEnd::TableHead => { 2875 - if self.render_tables_as_markdown { 2876 - Ok(()) // Skip HTML rendering 2877 - } else { 2878 - self.write("</tr></thead><tbody>\n")?; 2879 - self.table_state = TableState::Body; 2880 - Ok(()) 2881 - } 2882 - } 2883 - TagEnd::TableRow => { 2884 - if self.render_tables_as_markdown { 2885 - Ok(()) // Skip HTML rendering 2886 - } else { 2887 - self.write("</tr>\n") 2888 - } 2889 - } 2890 - TagEnd::TableCell => { 2891 - if self.render_tables_as_markdown { 2892 - Ok(()) // Skip HTML rendering 2893 - } else { 2894 - match self.table_state { 2895 - TableState::Head => self.write("</th>")?, 2896 - TableState::Body => self.write("</td>")?, 2897 - } 2898 - self.table_cell_index += 1; 2899 - Ok(()) 2900 - } 2901 - } 2902 - TagEnd::BlockQuote(_) => { 2903 - // If pending_blockquote_range is still set, the blockquote was empty 2904 - // (no paragraph inside). Emit the > as its own minimal paragraph. 2905 - let mut para_boundary = None; 2906 - if let Some(bq_range) = self.pending_blockquote_range.take() { 2907 - if bq_range.start < bq_range.end { 2908 - let raw_text = &self.source[bq_range.clone()]; 2909 - if let Some(gt_pos) = raw_text.find('>') { 2910 - let para_byte_start = bq_range.start + gt_pos; 2911 - let para_char_start = self.last_char_offset; 2912 - 2913 - // Create a minimal paragraph for the empty blockquote 2914 - let node_id = self.gen_node_id(); 2915 - write!(&mut self.writer, "<div id=\"{}\"", node_id)?; 2916 - 2917 - // Record start-of-node mapping for cursor positioning 2918 - self.offset_maps.push(OffsetMapping { 2919 - byte_range: para_byte_start..para_byte_start, 2920 - char_range: para_char_start..para_char_start, 2921 - node_id: node_id.clone(), 2922 - char_offset_in_node: gt_pos, 2923 - child_index: Some(0), 2924 - utf16_len: 0, 2925 - }); 2926 - 2927 - // Emit the > as block syntax 2928 - let syntax = &raw_text[gt_pos..gt_pos + 1]; 2929 - self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?; 2930 - 2931 - self.write("</div>\n")?; 2932 - self.end_node(); 2933 - 2934 - // Capture paragraph boundary for later finalization 2935 - let byte_range = para_byte_start..bq_range.end; 2936 - let char_range = para_char_start..self.last_char_offset; 2937 - para_boundary = Some((byte_range, char_range)); 2938 - } 2939 - } 2940 - } 2941 - self.write("</blockquote>\n")?; 2942 - self.close_wrapper()?; 2943 - 2944 - // Now finalize paragraph if we had one 2945 - if let Some((byte_range, char_range)) = para_boundary { 2946 - self.finalize_paragraph(byte_range, char_range); 2947 - } 2948 - Ok(()) 2949 - } 2950 - TagEnd::CodeBlock => { 2951 - use std::sync::LazyLock; 2952 - use syntect::parsing::SyntaxSet; 2953 - static SYNTAX_SET: LazyLock<SyntaxSet> = 2954 - LazyLock::new(|| SyntaxSet::load_defaults_newlines()); 2955 - 2956 - if let Some((lang, buffer)) = self.code_buffer.take() { 2957 - // Create offset mapping for code block content if we tracked ranges 2958 - if let (Some(code_byte_range), Some(code_char_range)) = ( 2959 - self.code_buffer_byte_range.take(), 2960 - self.code_buffer_char_range.take(), 2961 - ) { 2962 - // Record mapping before writing HTML 2963 - // (current_node_id should be set by start_tag for CodeBlock) 2964 - self.record_mapping(code_byte_range, code_char_range); 2965 - } 2966 - 2967 - // Get node_id for data-node-id attribute (needed for cursor positioning) 2968 - let node_id = self.current_node_id.clone(); 2969 - 2970 - if let Some(ref lang_str) = lang { 2971 - // Use a temporary String buffer for syntect 2972 - let mut temp_output = String::new(); 2973 - match weaver_renderer::code_pretty::highlight( 2974 - &SYNTAX_SET, 2975 - Some(lang_str), 2976 - &buffer, 2977 - &mut temp_output, 2978 - ) { 2979 - Ok(_) => { 2980 - // Inject data-node-id into the <pre> tag for cursor positioning 2981 - if let Some(ref nid) = node_id { 2982 - let injected = temp_output.replacen( 2983 - "<pre>", 2984 - &format!("<pre data-node-id=\"{}\">", nid), 2985 - 1, 2986 - ); 2987 - self.write(&injected)?; 2988 - } else { 2989 - self.write(&temp_output)?; 2990 - } 2991 - } 2992 - Err(_) => { 2993 - // Fallback to plain code block 2994 - if let Some(ref nid) = node_id { 2995 - write!( 2996 - &mut self.writer, 2997 - "<pre data-node-id=\"{}\"><code class=\"language-", 2998 - nid 2999 - )?; 3000 - } else { 3001 - self.write("<pre><code class=\"language-")?; 3002 - } 3003 - escape_html(&mut self.writer, lang_str)?; 3004 - self.write("\">")?; 3005 - escape_html_body_text(&mut self.writer, &buffer)?; 3006 - self.write("</code></pre>\n")?; 3007 - } 3008 - } 3009 - } else { 3010 - if let Some(ref nid) = node_id { 3011 - write!(&mut self.writer, "<pre data-node-id=\"{}\"><code>", nid)?; 3012 - } else { 3013 - self.write("<pre><code>")?; 3014 - } 3015 - escape_html_body_text(&mut self.writer, &buffer)?; 3016 - self.write("</code></pre>\n")?; 3017 - } 3018 - 3019 - // End node tracking 3020 - self.end_node(); 3021 - } else { 3022 - self.write("</code></pre>\n")?; 3023 - } 3024 - 3025 - // Emit closing ``` (emit_gap_before is skipped while buffering) 3026 - // Track the opening span index and char start before we potentially clear them 3027 - let opening_span_idx = self.code_block_opening_span_idx.take(); 3028 - let code_block_start = self.code_block_char_start.take(); 3029 - 3030 - if range.start < range.end { 3031 - let raw_text = &self.source[range.clone()]; 3032 - if let Some(fence_line) = raw_text.lines().last() { 3033 - if fence_line.trim().starts_with("```") { 3034 - let fence = fence_line.trim(); 3035 - let fence_char_len = fence.chars().count(); 3036 - 3037 - let syn_id = self.gen_syn_id(); 3038 - let char_start = self.last_char_offset; 3039 - let char_end = char_start + fence_char_len; 3040 - 3041 - write!( 3042 - &mut self.writer, 3043 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 3044 - syn_id, char_start, char_end 3045 - )?; 3046 - escape_html(&mut self.writer, fence)?; 3047 - self.write("</span>")?; 3048 - 3049 - self.last_char_offset += fence_char_len; 3050 - self.last_byte_offset += fence.len(); 3051 - 3052 - // Compute formatted_range for entire code block (opening fence to closing fence) 3053 - let formatted_range = 3054 - code_block_start.map(|start| start..self.last_char_offset); 3055 - 3056 - // Update opening fence span with formatted_range 3057 - if let (Some(idx), Some(fr)) = 3058 - (opening_span_idx, formatted_range.as_ref()) 3059 - { 3060 - if let Some(span) = self.syntax_spans.get_mut(idx) { 3061 - span.formatted_range = Some(fr.clone()); 3062 - } 3063 - } 3064 - 3065 - // Push closing fence span with formatted_range 3066 - self.syntax_spans.push(SyntaxSpanInfo { 3067 - syn_id, 3068 - char_range: char_start..char_end, 3069 - syntax_type: SyntaxType::Block, 3070 - formatted_range, 3071 - }); 3072 - } 3073 - } 3074 - } 3075 - 3076 - // Finalize code block paragraph 3077 - if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 3078 - let byte_range = byte_start..self.last_byte_offset; 3079 - let char_range = char_start..self.last_char_offset; 3080 - self.finalize_paragraph(byte_range, char_range); 3081 - } 3082 - 3083 - Ok(()) 3084 - } 3085 - TagEnd::List(true) => { 3086 - self.list_depth = self.list_depth.saturating_sub(1); 3087 - // Capture paragraph boundary BEFORE writing closing HTML 3088 - let para_boundary = 3089 - self.current_paragraph_start 3090 - .take() 3091 - .map(|(byte_start, char_start)| { 3092 - ( 3093 - byte_start..self.last_byte_offset, 3094 - char_start..self.last_char_offset, 3095 - ) 3096 - }); 3097 - 3098 - self.write("</ol>\n")?; 3099 - self.close_wrapper()?; 3100 - 3101 - // Finalize paragraph after closing HTML 3102 - if let Some((byte_range, char_range)) = para_boundary { 3103 - self.finalize_paragraph(byte_range, char_range); 3104 - } 3105 - Ok(()) 3106 - } 3107 - TagEnd::List(false) => { 3108 - self.list_depth = self.list_depth.saturating_sub(1); 3109 - // Capture paragraph boundary BEFORE writing closing HTML 3110 - let para_boundary = 3111 - self.current_paragraph_start 3112 - .take() 3113 - .map(|(byte_start, char_start)| { 3114 - ( 3115 - byte_start..self.last_byte_offset, 3116 - char_start..self.last_char_offset, 3117 - ) 3118 - }); 3119 - 3120 - self.write("</ul>\n")?; 3121 - self.close_wrapper()?; 3122 - 3123 - // Finalize paragraph after closing HTML 3124 - if let Some((byte_range, char_range)) = para_boundary { 3125 - self.finalize_paragraph(byte_range, char_range); 3126 - } 3127 - Ok(()) 3128 - } 3129 - TagEnd::Item => { 3130 - self.end_node(); 3131 - self.write("</li>\n") 3132 - } 3133 - TagEnd::DefinitionList => { 3134 - self.write("</dl>\n")?; 3135 - self.close_wrapper() 3136 - } 3137 - TagEnd::DefinitionListTitle => { 3138 - self.end_node(); 3139 - self.write("</dt>\n") 3140 - } 3141 - TagEnd::DefinitionListDefinition => { 3142 - self.end_node(); 3143 - self.write("</dd>\n") 3144 - } 3145 - TagEnd::Emphasis => { 3146 - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 3147 - self.write("</em>")?; 3148 - self.emit_gap_before(range.end)?; 3149 - self.finalize_paired_inline_format(); 3150 - Ok(()) 3151 - } 3152 - TagEnd::Superscript => self.write("</sup>"), 3153 - TagEnd::Subscript => self.write("</sub>"), 3154 - TagEnd::Strong => { 3155 - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 3156 - self.write("</strong>")?; 3157 - self.emit_gap_before(range.end)?; 3158 - self.finalize_paired_inline_format(); 3159 - Ok(()) 3160 - } 3161 - TagEnd::Strikethrough => { 3162 - // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 3163 - self.write("</s>")?; 3164 - self.emit_gap_before(range.end)?; 3165 - self.finalize_paired_inline_format(); 3166 - Ok(()) 3167 - } 3168 - TagEnd::Link => { 3169 - self.write("</a>")?; 3170 - // Check if this is a wiki link (ends with ]]) vs regular link (ends with )) 3171 - let raw_text = &self.source[range.clone()]; 3172 - if raw_text.ends_with("]]") { 3173 - // WikiLink: emit ]] as closing syntax 3174 - let syn_id = self.gen_syn_id(); 3175 - let char_start = self.last_char_offset; 3176 - let char_end = char_start + 2; 3177 - 3178 - write!( 3179 - &mut self.writer, 3180 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 3181 - syn_id, char_start, char_end 3182 - )?; 3183 - 3184 - self.syntax_spans.push(SyntaxSpanInfo { 3185 - syn_id, 3186 - char_range: char_start..char_end, 3187 - syntax_type: SyntaxType::Inline, 3188 - formatted_range: None, // Will be set by finalize 3189 - }); 3190 - 3191 - self.last_char_offset = char_end; 3192 - self.last_byte_offset = range.end; 3193 - } else { 3194 - self.emit_gap_before(range.end)?; 3195 - } 3196 - self.finalize_paired_inline_format(); 3197 - Ok(()) 3198 - } 3199 - TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event 3200 - TagEnd::Embed => Ok(()), 3201 - TagEnd::WeaverBlock(_) => { 3202 - self.in_non_writing_block = false; 3203 - 3204 - // Emit the { content } as a hideable syntax span 3205 - if let Some(char_start) = self.weaver_block_char_start.take() { 3206 - // Build the full syntax text: { buffered_content } 3207 - let syntax_text = format!("{{{}}}", self.weaver_block_buffer); 3208 - let syntax_char_len = syntax_text.chars().count(); 3209 - let char_end = char_start + syntax_char_len; 3210 - 3211 - let syn_id = self.gen_syn_id(); 3212 - 3213 - write!( 3214 - &mut self.writer, 3215 - "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 3216 - syn_id, char_start, char_end 3217 - )?; 3218 - escape_html(&mut self.writer, &syntax_text)?; 3219 - self.write("</span>")?; 3220 - 3221 - // Track the syntax span 3222 - self.syntax_spans.push(SyntaxSpanInfo { 3223 - syn_id, 3224 - char_range: char_start..char_end, 3225 - syntax_type: SyntaxType::Block, 3226 - formatted_range: None, 3227 - }); 3228 - 3229 - // Record offset mapping for the syntax span 3230 - self.record_mapping(range.clone(), char_start..char_end); 3231 - 3232 - // Update tracking 3233 - self.last_char_offset = char_end; 3234 - self.last_byte_offset = range.end; 3235 - } 3236 - 3237 - // Parse the buffered text for attrs and store for next block 3238 - if !self.weaver_block_buffer.is_empty() { 3239 - let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer); 3240 - self.weaver_block_buffer.clear(); 3241 - // Merge with any existing pending attrs or set new 3242 - if let Some(ref mut existing) = self.pending_block_attrs { 3243 - existing.classes.extend(parsed.classes); 3244 - existing.attrs.extend(parsed.attrs); 3245 - } else { 3246 - self.pending_block_attrs = Some(parsed); 3247 - } 3248 - } 3249 - 3250 - Ok(()) 3251 - } 3252 - TagEnd::FootnoteDefinition => { 3253 - self.write("</div>\n")?; 3254 - 3255 - // Link the footnote definition span with its reference span 3256 - if let Some((name, def_span_index, _def_char_start)) = 3257 - self.current_footnote_def.take() 3258 - { 3259 - let def_char_end = self.last_char_offset; 3260 - 3261 - // Look up the reference span 3262 - if let Some(&(ref_span_index, ref_char_start)) = 3263 - self.footnote_ref_spans.get(&name) 3264 - { 3265 - // Create formatted_range spanning from ref start to def end 3266 - let formatted_range = ref_char_start..def_char_end; 3267 - 3268 - // Update both spans with the same formatted_range 3269 - // so they show/hide together based on cursor proximity 3270 - if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) { 3271 - ref_span.formatted_range = Some(formatted_range.clone()); 3272 - } 3273 - if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) { 3274 - def_span.formatted_range = Some(formatted_range); 3275 - } 3276 - } 3277 - } 3278 - 3279 - Ok(()) 3280 - } 3281 - TagEnd::MetadataBlock(_) => { 3282 - self.in_non_writing_block = false; 3283 - Ok(()) 3284 - } 3285 - }; 3286 - 3287 - result?; 3288 - 3289 - // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough) 3290 - // is handled INSIDE their respective match arms above, AFTER writing the closing HTML. 3291 - // This ensures the closing syntax span appears OUTSIDE the formatted element. 3292 - // Other End events have their closing syntax emitted by emit_gap_before() in the main loop. 3293 - 3294 - Ok(()) 3295 - } 3296 - } 3297 - 3298 - impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> 3299 - EditorWriter<'a, I, E, R> 3300 - { 3301 - fn write_embed( 3302 - &mut self, 3303 - range: Range<usize>, 3304 - embed_type: EmbedType, 3305 - dest_url: CowStr<'_>, 3306 - title: CowStr<'_>, 3307 - id: CowStr<'_>, 3308 - attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 3309 - ) -> Result<(), fmt::Error> { 3310 - // Embed rendering: all syntax elements share one syn_id for visibility toggling 3311 - // Structure: ![[ url-as-link ]] <embed-content> 3312 - let raw_text = &self.source[range.clone()]; 3313 - let syn_id = self.gen_syn_id(); 3314 - let opening_char_start = self.last_char_offset; 3315 - 3316 - // Extract the URL from raw text (between ![[ and ]]) 3317 - let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") { 3318 - &raw_text[3..raw_text.len() - 2] 3319 - } else { 3320 - dest_url.as_ref() 3321 - }; 3322 - 3323 - // Calculate char positions 3324 - let url_char_len = url_text.chars().count(); 3325 - let opening_char_end = opening_char_start + 3; // "![[" 3326 - let url_char_start = opening_char_end; 3327 - let url_char_end = url_char_start + url_char_len; 3328 - let closing_char_start = url_char_end; 3329 - let closing_char_end = closing_char_start + 2; // "]]" 3330 - let formatted_range = opening_char_start..closing_char_end; 3331 - 3332 - // 1. Emit opening ![[ syntax span 3333 - if raw_text.starts_with("![[") { 3334 - write!( 3335 - &mut self.writer, 3336 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>", 3337 - syn_id, opening_char_start, opening_char_end 3338 - )?; 3339 - 3340 - self.syntax_spans.push(SyntaxSpanInfo { 3341 - syn_id: syn_id.clone(), 3342 - char_range: opening_char_start..opening_char_end, 3343 - syntax_type: SyntaxType::Inline, 3344 - formatted_range: Some(formatted_range.clone()), 3345 - }); 3346 - 3347 - self.record_mapping( 3348 - range.start..range.start + 3, 3349 - opening_char_start..opening_char_end, 3350 - ); 3351 - } 3352 - 3353 - // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax) 3354 - let url = dest_url.as_ref(); 3355 - let link_href = if url.starts_with("at://") { 3356 - format!("https://alpha.weaver.sh/record/{}", url) 3357 - } else { 3358 - url.to_string() 3359 - }; 3360 - 3361 - write!( 3362 - &mut self.writer, 3363 - "<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">", 3364 - link_href, syn_id, url_char_start, url_char_end 3365 - )?; 3366 - escape_html(&mut self.writer, url_text)?; 3367 - self.write("</a>")?; 3368 - 3369 - self.syntax_spans.push(SyntaxSpanInfo { 3370 - syn_id: syn_id.clone(), 3371 - char_range: url_char_start..url_char_end, 3372 - syntax_type: SyntaxType::Inline, 3373 - formatted_range: Some(formatted_range.clone()), 3374 - }); 3375 - 3376 - self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end); 3377 - 3378 - // 3. Emit closing ]] syntax span 3379 - if raw_text.ends_with("]]") { 3380 - write!( 3381 - &mut self.writer, 3382 - "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 3383 - syn_id, closing_char_start, closing_char_end 3384 - )?; 3385 - 3386 - self.syntax_spans.push(SyntaxSpanInfo { 3387 - syn_id: syn_id.clone(), 3388 - char_range: closing_char_start..closing_char_end, 3389 - syntax_type: SyntaxType::Inline, 3390 - formatted_range: Some(formatted_range.clone()), 3391 - }); 3392 - 3393 - self.record_mapping( 3394 - range.end - 2..range.end, 3395 - closing_char_start..closing_char_end, 3396 - ); 3397 - } 3398 - 3399 - // Collect AT URI for later resolution 3400 - if url.starts_with("at://") || url.starts_with("did:") { 3401 - self.ref_collector.add_at_embed( 3402 - url, 3403 - if title.is_empty() { 3404 - None 3405 - } else { 3406 - Some(title.as_ref()) 3407 - }, 3408 - ); 3409 - } 3410 - 3411 - // 4. Emit the actual embed content 3412 - // Try to get content from attributes first 3413 - let content_from_attrs = if let Some(ref attrs) = attrs { 3414 - attrs 3415 - .attrs 3416 - .iter() 3417 - .find(|(k, _)| k.as_ref() == "content") 3418 - .map(|(_, v)| v.as_ref().to_string()) 3419 - } else { 3420 - None 3421 - }; 3422 - 3423 - // If no content in attrs, try provider 3424 - let content = if let Some(content) = content_from_attrs { 3425 - Some(content) 3426 - } else if let Some(ref provider) = self.embed_provider { 3427 - let tag = Tag::Embed { 3428 - embed_type, 3429 - dest_url: dest_url.clone(), 3430 - title: title.clone(), 3431 - id: id.clone(), 3432 - attrs: attrs.clone(), 3433 - }; 3434 - provider.get_embed_content(&tag) 3435 - } else { 3436 - None 3437 - }; 3438 - 3439 - if let Some(html_content) = content { 3440 - // Write the pre-rendered content directly 3441 - self.write(&html_content)?; 3442 - } else { 3443 - // Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs) 3444 - self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?; 3445 - self.write("<span class=\"embed-loading\">Loading embed...</span>")?; 3446 - self.write("</div>")?; 3447 - } 3448 - 3449 - // Consume the text events for the URL (they're still in the iterator) 3450 - // Use consume_until_end() since we already wrote the URL from source 3451 - self.consume_until_end(); 3452 - 3453 - // Update offsets 3454 - self.last_char_offset = closing_char_end; 3455 - self.last_byte_offset = range.end; 3456 - 3457 1350 Ok(()) 3458 1351 } 3459 1352 }
+371
crates/weaver-app/src/components/editor/writer/embed.rs
··· 1 + use core::fmt; 2 + use std::ops::Range; 3 + 4 + use jacquard::types::{ident::AtIdentifier, string::Rkey}; 5 + use markdown_weaver::{CowStr, EmbedType, Event, Tag}; 6 + use markdown_weaver_escape::{StrWrite, escape_html}; 7 + use weaver_common::ResolvedContent; 8 + 9 + use crate::components::editor::{ 10 + SyntaxSpanInfo, SyntaxType, document::EditorImage, writer::EditorWriter, 11 + }; 12 + 13 + /// Synchronous callback for injecting embed content 14 + /// 15 + /// Takes the embed tag and returns optional HTML content to inject. 16 + pub trait EmbedContentProvider { 17 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String>; 18 + } 19 + 20 + impl EmbedContentProvider for () { 21 + fn get_embed_content(&self, _tag: &Tag<'_>) -> Option<String> { 22 + None 23 + } 24 + } 25 + 26 + impl EmbedContentProvider for &ResolvedContent { 27 + fn get_embed_content(&self, tag: &Tag<'_>) -> Option<String> { 28 + if let Tag::Embed { dest_url, .. } = tag { 29 + let url = dest_url.as_ref(); 30 + if url.starts_with("at://") { 31 + if let Ok(at_uri) = jacquard::types::string::AtUri::new(url) { 32 + return ResolvedContent::get_embed_content(self, &at_uri) 33 + .map(|s| s.to_string()); 34 + } 35 + } 36 + } 37 + None 38 + } 39 + } 40 + 41 + /// Resolves image URLs to CDN URLs based on stored images. 42 + /// 43 + /// The markdown may reference images by name (e.g., "photo.jpg" or "/notebook/image.png"). 44 + /// This trait maps those names to the actual CDN URL using the blob CID and owner DID. 45 + pub trait ImageResolver { 46 + /// Resolve an image URL from markdown to a CDN URL. 47 + /// 48 + /// Returns `Some(cdn_url)` if the image is found, `None` to use the original URL. 49 + fn resolve_image_url(&self, url: &str) -> Option<String>; 50 + } 51 + 52 + impl ImageResolver for () { 53 + fn resolve_image_url(&self, _url: &str) -> Option<String> { 54 + None 55 + } 56 + } 57 + 58 + /// Concrete image resolver that maps image names to URLs. 59 + /// 60 + /// Resolved image path type 61 + #[derive(Clone, Debug)] 62 + enum ResolvedImage { 63 + /// Data URL for immediate preview (still uploading) 64 + Pending(String), 65 + /// Draft image: `/image/{ident}/draft/{blob_rkey}/{name}` 66 + Draft { 67 + blob_rkey: Rkey<'static>, 68 + ident: AtIdentifier<'static>, 69 + }, 70 + /// Published image: `/image/{ident}/{entry_rkey}/{name}` 71 + Published { 72 + entry_rkey: Rkey<'static>, 73 + ident: AtIdentifier<'static>, 74 + }, 75 + } 76 + 77 + /// Resolves image paths in the editor. 78 + /// 79 + /// Supports three states for images: 80 + /// - Pending: uses data URL for immediate preview while upload is in progress 81 + /// - Draft: uses path format `/image/{did}/draft/{blob_rkey}/{name}` 82 + /// - Published: uses path format `/image/{did}/{entry_rkey}/{name}` 83 + /// 84 + /// Image URLs in markdown use the format `/image/{name}`. 85 + #[derive(Clone, Default)] 86 + pub struct EditorImageResolver { 87 + /// All resolved images: name -> resolved path info 88 + images: std::collections::HashMap<String, ResolvedImage>, 89 + } 90 + 91 + impl EditorImageResolver { 92 + pub fn new() -> Self { 93 + Self::default() 94 + } 95 + 96 + /// Add a pending image with a data URL for immediate preview. 97 + pub fn add_pending(&mut self, name: String, data_url: String) { 98 + self.images.insert(name, ResolvedImage::Pending(data_url)); 99 + } 100 + 101 + /// Promote a pending image to uploaded (draft) status. 102 + pub fn promote_to_uploaded( 103 + &mut self, 104 + name: &str, 105 + blob_rkey: Rkey<'static>, 106 + ident: AtIdentifier<'static>, 107 + ) { 108 + self.images 109 + .insert(name.to_string(), ResolvedImage::Draft { blob_rkey, ident }); 110 + } 111 + 112 + /// Add an already-uploaded draft image. 113 + pub fn add_uploaded( 114 + &mut self, 115 + name: String, 116 + blob_rkey: Rkey<'static>, 117 + ident: AtIdentifier<'static>, 118 + ) { 119 + self.images 120 + .insert(name, ResolvedImage::Draft { blob_rkey, ident }); 121 + } 122 + 123 + /// Add a published image. 124 + pub fn add_published( 125 + &mut self, 126 + name: String, 127 + entry_rkey: Rkey<'static>, 128 + ident: AtIdentifier<'static>, 129 + ) { 130 + self.images 131 + .insert(name, ResolvedImage::Published { entry_rkey, ident }); 132 + } 133 + 134 + /// Check if an image is pending upload. 135 + pub fn is_pending(&self, name: &str) -> bool { 136 + matches!(self.images.get(name), Some(ResolvedImage::Pending(_))) 137 + } 138 + 139 + /// Build a resolver from editor images and user identifier. 140 + /// 141 + /// For draft mode (entry_rkey=None), only images with a `published_blob_uri` are included. 142 + /// For published mode (entry_rkey=Some), all images are included. 143 + pub fn from_images<'a>( 144 + images: impl IntoIterator<Item = &'a EditorImage>, 145 + ident: AtIdentifier<'static>, 146 + entry_rkey: Option<Rkey<'static>>, 147 + ) -> Self { 148 + use jacquard::IntoStatic; 149 + 150 + let mut resolver = Self::new(); 151 + for editor_image in images { 152 + // Get the name from the Image (use alt text as fallback if name is empty) 153 + let name = editor_image 154 + .image 155 + .name 156 + .as_ref() 157 + .map(|n| n.to_string()) 158 + .unwrap_or_else(|| editor_image.image.alt.to_string()); 159 + 160 + if name.is_empty() { 161 + continue; 162 + } 163 + 164 + match &entry_rkey { 165 + // Published mode: use entry rkey for all images 166 + Some(rkey) => { 167 + resolver.add_published(name, rkey.clone(), ident.clone()); 168 + } 169 + // Draft mode: use published_blob_uri rkey 170 + None => { 171 + let blob_rkey = match &editor_image.published_blob_uri { 172 + Some(uri) => match uri.rkey() { 173 + Some(rkey) => rkey.0.clone().into_static(), 174 + None => continue, 175 + }, 176 + None => continue, 177 + }; 178 + resolver.add_uploaded(name, blob_rkey, ident.clone()); 179 + } 180 + } 181 + } 182 + resolver 183 + } 184 + } 185 + 186 + impl ImageResolver for EditorImageResolver { 187 + fn resolve_image_url(&self, url: &str) -> Option<String> { 188 + // Extract image name from /image/{name} format 189 + let name = url.strip_prefix("/image/").unwrap_or(url); 190 + 191 + let resolved = self.images.get(name)?; 192 + match resolved { 193 + ResolvedImage::Pending(data_url) => Some(data_url.clone()), 194 + ResolvedImage::Draft { blob_rkey, ident } => { 195 + Some(format!("/image/{}/draft/{}/{}", ident, blob_rkey, name)) 196 + } 197 + ResolvedImage::Published { entry_rkey, ident } => { 198 + Some(format!("/image/{}/{}/{}", ident, entry_rkey, name)) 199 + } 200 + } 201 + } 202 + } 203 + 204 + impl ImageResolver for &EditorImageResolver { 205 + fn resolve_image_url(&self, url: &str) -> Option<String> { 206 + (*self).resolve_image_url(url) 207 + } 208 + } 209 + 210 + impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> 211 + EditorWriter<'a, I, E, R> 212 + { 213 + pub(crate) fn write_embed( 214 + &mut self, 215 + range: Range<usize>, 216 + embed_type: EmbedType, 217 + dest_url: CowStr<'_>, 218 + title: CowStr<'_>, 219 + id: CowStr<'_>, 220 + attrs: Option<markdown_weaver::WeaverAttributes<'_>>, 221 + ) -> Result<(), fmt::Error> { 222 + // Embed rendering: all syntax elements share one syn_id for visibility toggling 223 + // Structure: ![[ url-as-link ]] <embed-content> 224 + let raw_text = &self.source[range.clone()]; 225 + let syn_id = self.gen_syn_id(); 226 + let opening_char_start = self.last_char_offset; 227 + 228 + // Extract the URL from raw text (between ![[ and ]]) 229 + let url_text = if raw_text.starts_with("![[") && raw_text.ends_with("]]") { 230 + &raw_text[3..raw_text.len() - 2] 231 + } else { 232 + dest_url.as_ref() 233 + }; 234 + 235 + // Calculate char positions 236 + let url_char_len = url_text.chars().count(); 237 + let opening_char_end = opening_char_start + 3; // "![[" 238 + let url_char_start = opening_char_end; 239 + let url_char_end = url_char_start + url_char_len; 240 + let closing_char_start = url_char_end; 241 + let closing_char_end = closing_char_start + 2; // "]]" 242 + let formatted_range = opening_char_start..closing_char_end; 243 + 244 + // 1. Emit opening ![[ syntax span 245 + if raw_text.starts_with("![[") { 246 + write!( 247 + &mut self.writer, 248 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![[</span>", 249 + syn_id, opening_char_start, opening_char_end 250 + )?; 251 + 252 + self.syntax_spans.push(SyntaxSpanInfo { 253 + syn_id: syn_id.clone(), 254 + char_range: opening_char_start..opening_char_end, 255 + syntax_type: SyntaxType::Inline, 256 + formatted_range: Some(formatted_range.clone()), 257 + }); 258 + 259 + self.record_mapping( 260 + range.start..range.start + 3, 261 + opening_char_start..opening_char_end, 262 + ); 263 + } 264 + 265 + // 2. Emit URL as a clickable link (same syn_id, shown/hidden with syntax) 266 + let url = dest_url.as_ref(); 267 + let link_href = if url.starts_with("at://") { 268 + format!("https://alpha.weaver.sh/record/{}", url) 269 + } else { 270 + url.to_string() 271 + }; 272 + 273 + write!( 274 + &mut self.writer, 275 + "<a class=\"image-alt embed-url\" href=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" target=\"_blank\">", 276 + link_href, syn_id, url_char_start, url_char_end 277 + )?; 278 + escape_html(&mut self.writer, url_text)?; 279 + self.write("</a>")?; 280 + 281 + self.syntax_spans.push(SyntaxSpanInfo { 282 + syn_id: syn_id.clone(), 283 + char_range: url_char_start..url_char_end, 284 + syntax_type: SyntaxType::Inline, 285 + formatted_range: Some(formatted_range.clone()), 286 + }); 287 + 288 + self.record_mapping(range.start + 3..range.end - 2, url_char_start..url_char_end); 289 + 290 + // 3. Emit closing ]] syntax span 291 + if raw_text.ends_with("]]") { 292 + write!( 293 + &mut self.writer, 294 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 295 + syn_id, closing_char_start, closing_char_end 296 + )?; 297 + 298 + self.syntax_spans.push(SyntaxSpanInfo { 299 + syn_id: syn_id.clone(), 300 + char_range: closing_char_start..closing_char_end, 301 + syntax_type: SyntaxType::Inline, 302 + formatted_range: Some(formatted_range.clone()), 303 + }); 304 + 305 + self.record_mapping( 306 + range.end - 2..range.end, 307 + closing_char_start..closing_char_end, 308 + ); 309 + } 310 + 311 + // Collect AT URI for later resolution 312 + if url.starts_with("at://") || url.starts_with("did:") { 313 + self.ref_collector.add_at_embed( 314 + url, 315 + if title.is_empty() { 316 + None 317 + } else { 318 + Some(title.as_ref()) 319 + }, 320 + ); 321 + } 322 + 323 + // 4. Emit the actual embed content 324 + // Try to get content from attributes first 325 + let content_from_attrs = if let Some(ref attrs) = attrs { 326 + attrs 327 + .attrs 328 + .iter() 329 + .find(|(k, _)| k.as_ref() == "content") 330 + .map(|(_, v)| v.as_ref().to_string()) 331 + } else { 332 + None 333 + }; 334 + 335 + // If no content in attrs, try provider 336 + let content = if let Some(content) = content_from_attrs { 337 + Some(content) 338 + } else if let Some(ref provider) = self.embed_provider { 339 + let tag = Tag::Embed { 340 + embed_type, 341 + dest_url: dest_url.clone(), 342 + title: title.clone(), 343 + id: id.clone(), 344 + attrs: attrs.clone(), 345 + }; 346 + provider.get_embed_content(&tag) 347 + } else { 348 + None 349 + }; 350 + 351 + if let Some(html_content) = content { 352 + // Write the pre-rendered content directly 353 + self.write(&html_content)?; 354 + } else { 355 + // Fallback: render as placeholder div (iframe doesn't make sense for at:// URIs) 356 + self.write("<div class=\"atproto-embed atproto-embed-placeholder\">")?; 357 + self.write("<span class=\"embed-loading\">Loading embed...</span>")?; 358 + self.write("</div>")?; 359 + } 360 + 361 + // Consume the text events for the URL (they're still in the iterator) 362 + // Use consume_until_end() since we already wrote the URL from source 363 + self.consume_until_end(); 364 + 365 + // Update offsets 366 + self.last_char_offset = closing_char_end; 367 + self.last_byte_offset = range.end; 368 + 369 + Ok(()) 370 + } 371 + }
+66
crates/weaver-app/src/components/editor/writer/segmented.rs
··· 1 + use core::fmt; 2 + 3 + use markdown_weaver_escape::StrWrite; 4 + 5 + /// Writer that segments output by paragraph boundaries. 6 + /// 7 + /// Each paragraph's HTML is written to a separate String in the segments Vec. 8 + /// Call `new_segment()` at paragraph boundaries to start a new segment. 9 + #[derive(Debug, Clone, Default)] 10 + pub struct SegmentedWriter { 11 + pub segments: Vec<String>, 12 + } 13 + 14 + #[allow(dead_code)] 15 + impl SegmentedWriter { 16 + pub fn new() -> Self { 17 + Self { 18 + segments: vec![String::new()], 19 + } 20 + } 21 + 22 + /// Start a new segment for the next paragraph. 23 + pub fn new_segment(&mut self) { 24 + self.segments.push(String::new()); 25 + } 26 + 27 + /// Get the completed segments. 28 + pub fn into_segments(self) -> Vec<String> { 29 + self.segments 30 + } 31 + 32 + /// Get current segment count. 33 + pub fn segment_count(&self) -> usize { 34 + self.segments.len() 35 + } 36 + } 37 + 38 + impl StrWrite for SegmentedWriter { 39 + type Error = fmt::Error; 40 + 41 + #[inline] 42 + fn write_str(&mut self, s: &str) -> Result<(), Self::Error> { 43 + if let Some(segment) = self.segments.last_mut() { 44 + segment.push_str(s); 45 + } 46 + Ok(()) 47 + } 48 + 49 + #[inline] 50 + fn write_fmt(&mut self, args: fmt::Arguments) -> Result<(), Self::Error> { 51 + if let Some(segment) = self.segments.last_mut() { 52 + fmt::Write::write_fmt(segment, args)?; 53 + } 54 + Ok(()) 55 + } 56 + } 57 + 58 + impl fmt::Write for SegmentedWriter { 59 + fn write_str(&mut self, s: &str) -> fmt::Result { 60 + <Self as StrWrite>::write_str(self, s) 61 + } 62 + 63 + fn write_fmt(&mut self, args: fmt::Arguments<'_>) -> fmt::Result { 64 + <Self as StrWrite>::write_fmt(self, args) 65 + } 66 + }
+264
crates/weaver-app/src/components/editor/writer/syntax.rs
··· 1 + use core::fmt; 2 + use std::ops::Range; 3 + 4 + use markdown_weaver::Event; 5 + use markdown_weaver_escape::{StrWrite, escape_html}; 6 + 7 + use crate::components::editor::writer::{ 8 + EditorWriter, 9 + embed::{EmbedContentProvider, ImageResolver}, 10 + }; 11 + 12 + /// Classification of markdown syntax characters 13 + #[derive(Debug, Clone, Copy, PartialEq, Eq)] 14 + pub enum SyntaxType { 15 + /// Inline formatting: **, *, ~~, `, $, [, ], (, ) 16 + Inline, 17 + /// Block formatting: #, >, -, *, 1., ```, --- 18 + Block, 19 + } 20 + 21 + /// Information about a syntax span for conditional visibility 22 + #[derive(Debug, Clone, PartialEq, Eq)] 23 + pub struct SyntaxSpanInfo { 24 + /// Unique identifier for this syntax span (e.g., "s0", "s1") 25 + pub syn_id: String, 26 + /// Source char range this syntax covers (just this marker) 27 + pub char_range: Range<usize>, 28 + /// Whether this is inline or block-level syntax 29 + pub syntax_type: SyntaxType, 30 + /// For paired inline syntax (**, *, etc), the full formatted region 31 + /// from opening marker through content to closing marker. 32 + /// When cursor is anywhere in this range, the syntax is visible. 33 + pub formatted_range: Option<Range<usize>>, 34 + } 35 + 36 + impl SyntaxSpanInfo { 37 + /// Adjust all position fields by a character delta. 38 + /// 39 + /// This adjusts both `char_range` and `formatted_range` (if present) together, 40 + /// ensuring they stay in sync. Use this instead of manually adjusting fields 41 + /// to avoid forgetting one. 42 + pub fn adjust_positions(&mut self, char_delta: isize) { 43 + self.char_range.start = (self.char_range.start as isize + char_delta) as usize; 44 + self.char_range.end = (self.char_range.end as isize + char_delta) as usize; 45 + if let Some(ref mut fr) = self.formatted_range { 46 + fr.start = (fr.start as isize + char_delta) as usize; 47 + fr.end = (fr.end as isize + char_delta) as usize; 48 + } 49 + } 50 + } 51 + 52 + /// Classify syntax text as inline or block level 53 + pub(crate) fn classify_syntax(text: &str) -> SyntaxType { 54 + let trimmed = text.trim_start(); 55 + 56 + // Check for block-level markers 57 + if trimmed.starts_with('#') 58 + || trimmed.starts_with('>') 59 + || trimmed.starts_with("```") 60 + || trimmed.starts_with("---") 61 + || (trimmed.starts_with('-') 62 + && trimmed 63 + .chars() 64 + .nth(1) 65 + .map(|c| c.is_whitespace()) 66 + .unwrap_or(false)) 67 + || (trimmed.starts_with('*') 68 + && trimmed 69 + .chars() 70 + .nth(1) 71 + .map(|c| c.is_whitespace()) 72 + .unwrap_or(false)) 73 + || trimmed 74 + .chars() 75 + .next() 76 + .map(|c| c.is_ascii_digit()) 77 + .unwrap_or(false) 78 + && trimmed.contains('.') 79 + { 80 + SyntaxType::Block 81 + } else { 82 + SyntaxType::Inline 83 + } 84 + } 85 + 86 + impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> 87 + EditorWriter<'a, I, E, R> 88 + { 89 + /// Emit syntax span for a given range and record offset mapping 90 + pub(crate) fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), fmt::Error> { 91 + if range.start < range.end { 92 + let syntax = &self.source[range.clone()]; 93 + if !syntax.is_empty() { 94 + let char_start = self.last_char_offset; 95 + let syntax_char_len = syntax.chars().count(); 96 + let char_end = char_start + syntax_char_len; 97 + 98 + tracing::trace!( 99 + target: "weaver::writer", 100 + byte_range = ?range, 101 + char_range = ?(char_start..char_end), 102 + syntax = %syntax.escape_debug(), 103 + "emit_syntax" 104 + ); 105 + 106 + // Whitespace-only content (trailing spaces, newlines) should be emitted 107 + // as plain text, not wrapped in a hideable syntax span 108 + let is_whitespace_only = syntax.trim().is_empty(); 109 + 110 + if is_whitespace_only { 111 + // Emit as plain text with tracking span (not hideable) 112 + let created_node = if self.current_node_id.is_none() { 113 + let node_id = self.gen_node_id(); 114 + write!(&mut self.writer, "<span id=\"{}\">", node_id)?; 115 + self.begin_node(node_id); 116 + true 117 + } else { 118 + false 119 + }; 120 + 121 + escape_html(&mut self.writer, syntax)?; 122 + 123 + // Record offset mapping BEFORE end_node (which clears current_node_id) 124 + self.record_mapping(range.clone(), char_start..char_end); 125 + self.last_char_offset = char_end; 126 + self.last_byte_offset = range.end; 127 + 128 + if created_node { 129 + self.write("</span>")?; 130 + self.end_node(); 131 + } 132 + } else { 133 + // Real syntax - wrap in hideable span 134 + let syntax_type = classify_syntax(syntax); 135 + let class = match syntax_type { 136 + SyntaxType::Inline => "md-syntax-inline", 137 + SyntaxType::Block => "md-syntax-block", 138 + }; 139 + 140 + // Generate unique ID for this syntax span 141 + let syn_id = self.gen_syn_id(); 142 + 143 + // If we're outside any node, create a wrapper span for tracking 144 + let created_node = if self.current_node_id.is_none() { 145 + let node_id = self.gen_node_id(); 146 + write!( 147 + &mut self.writer, 148 + "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 149 + node_id, class, syn_id, char_start, char_end 150 + )?; 151 + self.begin_node(node_id); 152 + true 153 + } else { 154 + write!( 155 + &mut self.writer, 156 + "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 157 + class, syn_id, char_start, char_end 158 + )?; 159 + false 160 + }; 161 + 162 + escape_html(&mut self.writer, syntax)?; 163 + self.write("</span>")?; 164 + 165 + // Record syntax span info for visibility toggling 166 + self.syntax_spans.push(SyntaxSpanInfo { 167 + syn_id, 168 + char_range: char_start..char_end, 169 + syntax_type, 170 + formatted_range: None, 171 + }); 172 + 173 + // Record offset mapping for this syntax 174 + self.record_mapping(range.clone(), char_start..char_end); 175 + self.last_char_offset = char_end; 176 + self.last_byte_offset = range.end; 177 + 178 + // Close wrapper if we created one 179 + if created_node { 180 + self.write("</span>")?; 181 + self.end_node(); 182 + } 183 + } 184 + } 185 + } 186 + Ok(()) 187 + } 188 + 189 + /// Emit syntax span inside current node with full offset tracking. 190 + /// 191 + /// Use this for syntax markers that appear inside block elements (headings, lists, 192 + /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates 193 + /// wrapper nodes, this assumes we're already inside a tracked node. 194 + /// 195 + /// - Writes `<span class="md-syntax-{class}">{syntax}</span>` 196 + /// - Records offset mapping (for cursor positioning) 197 + /// - Updates both `last_char_offset` and `last_byte_offset` 198 + pub(crate) fn emit_inner_syntax( 199 + &mut self, 200 + syntax: &str, 201 + byte_start: usize, 202 + syntax_type: SyntaxType, 203 + ) -> Result<(), fmt::Error> { 204 + if syntax.is_empty() { 205 + return Ok(()); 206 + } 207 + 208 + let char_start = self.last_char_offset; 209 + let syntax_char_len = syntax.chars().count(); 210 + let char_end = char_start + syntax_char_len; 211 + let byte_end = byte_start + syntax.len(); 212 + 213 + let class_str = match syntax_type { 214 + SyntaxType::Inline => "md-syntax-inline", 215 + SyntaxType::Block => "md-syntax-block", 216 + }; 217 + 218 + // Generate unique ID for this syntax span 219 + let syn_id = self.gen_syn_id(); 220 + 221 + write!( 222 + &mut self.writer, 223 + "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 224 + class_str, syn_id, char_start, char_end 225 + )?; 226 + escape_html(&mut self.writer, syntax)?; 227 + self.write("</span>")?; 228 + 229 + // Record syntax span info for visibility toggling 230 + self.syntax_spans.push(SyntaxSpanInfo { 231 + syn_id, 232 + char_range: char_start..char_end, 233 + syntax_type, 234 + formatted_range: None, 235 + }); 236 + 237 + // Record offset mapping for cursor positioning 238 + self.record_mapping(byte_start..byte_end, char_start..char_end); 239 + 240 + self.last_char_offset = char_end; 241 + self.last_byte_offset = byte_end; 242 + 243 + Ok(()) 244 + } 245 + 246 + /// Emit any gap between last position and next offset 247 + pub(crate) fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> { 248 + // Skip gap emission if we're inside a table being rendered as markdown 249 + if self.table_start_offset.is_some() && self.render_tables_as_markdown { 250 + return Ok(()); 251 + } 252 + 253 + // Skip gap emission if we're buffering code block content 254 + // The code block handler manages its own syntax emission 255 + if self.code_buffer.is_some() { 256 + return Ok(()); 257 + } 258 + 259 + if next_offset > self.last_byte_offset { 260 + self.emit_syntax(self.last_byte_offset..next_offset)?; 261 + } 262 + Ok(()) 263 + } 264 + }
+1464
crates/weaver-app/src/components/editor/writer/tags.rs
··· 1 + use core::fmt; 2 + use std::ops::Range; 3 + 4 + use markdown_weaver::{Alignment, BlockQuoteKind, CodeBlockKind, EmbedType, Event, LinkType, Tag}; 5 + use markdown_weaver_escape::{StrWrite, escape_href, escape_html, escape_html_body_text}; 6 + 7 + use crate::components::editor::{ 8 + OffsetMapping, SyntaxSpanInfo, SyntaxType, 9 + writer::{ 10 + EditorWriter, TableState, 11 + embed::{EmbedContentProvider, ImageResolver}, 12 + syntax::classify_syntax, 13 + }, 14 + }; 15 + 16 + impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> 17 + EditorWriter<'a, I, E, R> 18 + { 19 + pub(crate) fn start_tag( 20 + &mut self, 21 + tag: Tag<'_>, 22 + range: Range<usize>, 23 + ) -> Result<(), fmt::Error> { 24 + // Check if this is a block-level tag that should have syntax inside 25 + let is_block_tag = matches!(tag, Tag::Heading { .. } | Tag::BlockQuote(_)); 26 + 27 + // For inline tags, emit syntax before tag 28 + if !is_block_tag && range.start < range.end { 29 + let raw_text = &self.source[range.clone()]; 30 + let opening_syntax = match &tag { 31 + Tag::Strong => { 32 + if raw_text.starts_with("**") { 33 + Some("**") 34 + } else if raw_text.starts_with("__") { 35 + Some("__") 36 + } else { 37 + None 38 + } 39 + } 40 + Tag::Emphasis => { 41 + if raw_text.starts_with("*") { 42 + Some("*") 43 + } else if raw_text.starts_with("_") { 44 + Some("_") 45 + } else { 46 + None 47 + } 48 + } 49 + Tag::Strikethrough => { 50 + if raw_text.starts_with("~~") { 51 + Some("~~") 52 + } else { 53 + None 54 + } 55 + } 56 + Tag::Link { link_type, .. } => { 57 + if matches!(link_type, LinkType::WikiLink { .. }) { 58 + if raw_text.starts_with("[[") { 59 + Some("[[") 60 + } else { 61 + None 62 + } 63 + } else if raw_text.starts_with('[') { 64 + Some("[") 65 + } else { 66 + None 67 + } 68 + } 69 + // Note: Tag::Image and Tag::Embed handle their own syntax spans 70 + // in their respective handlers, so don't emit here 71 + _ => None, 72 + }; 73 + 74 + if let Some(syntax) = opening_syntax { 75 + let syntax_type = classify_syntax(syntax); 76 + let class = match syntax_type { 77 + SyntaxType::Inline => "md-syntax-inline", 78 + SyntaxType::Block => "md-syntax-block", 79 + }; 80 + 81 + let char_start = self.last_char_offset; 82 + let syntax_char_len = syntax.chars().count(); 83 + let char_end = char_start + syntax_char_len; 84 + let syntax_byte_len = syntax.len(); 85 + 86 + // Generate unique ID for this syntax span 87 + let syn_id = self.gen_syn_id(); 88 + 89 + write!( 90 + &mut self.writer, 91 + "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 92 + class, syn_id, char_start, char_end 93 + )?; 94 + escape_html(&mut self.writer, syntax)?; 95 + self.write("</span>")?; 96 + 97 + // Record syntax span info for visibility toggling 98 + self.syntax_spans.push(SyntaxSpanInfo { 99 + syn_id: syn_id.clone(), 100 + char_range: char_start..char_end, 101 + syntax_type, 102 + formatted_range: None, // Will be updated when closing tag is emitted 103 + }); 104 + 105 + // Record offset mapping for cursor positioning 106 + // This is critical - without it, current_node_char_offset is wrong 107 + // and all subsequent cursor positions are shifted 108 + let byte_start = range.start; 109 + let byte_end = range.start + syntax_byte_len; 110 + self.record_mapping(byte_start..byte_end, char_start..char_end); 111 + 112 + // For paired inline syntax, track opening span for formatted_range 113 + if matches!( 114 + tag, 115 + Tag::Strong | Tag::Emphasis | Tag::Strikethrough | Tag::Link { .. } 116 + ) { 117 + self.pending_inline_formats.push((syn_id, char_start)); 118 + } 119 + 120 + // Update tracking - we've consumed this opening syntax 121 + self.last_char_offset = char_end; 122 + self.last_byte_offset = range.start + syntax_byte_len; 123 + } 124 + } 125 + 126 + // Emit the opening tag 127 + match tag { 128 + // HTML blocks get their own paragraph to try and corral them better 129 + Tag::HtmlBlock => { 130 + // Record paragraph start for boundary tracking 131 + // BUT skip if inside a list - list owns the paragraph boundary 132 + if self.list_depth == 0 { 133 + self.current_paragraph_start = 134 + Some((self.last_byte_offset, self.last_char_offset)); 135 + } 136 + let node_id = self.gen_node_id(); 137 + 138 + if self.end_newline { 139 + write!( 140 + &mut self.writer, 141 + r#"<p id="{}" class="html-embed html-embed-block">"#, 142 + node_id 143 + )?; 144 + } else { 145 + write!( 146 + &mut self.writer, 147 + r#"\n<p id="{}" class="html-embed html-embed-block">"#, 148 + node_id 149 + )?; 150 + } 151 + self.begin_node(node_id.clone()); 152 + 153 + // Map the start position of the paragraph (before any content) 154 + // This allows cursor to be placed at the very beginning 155 + let para_start_char = self.last_char_offset; 156 + let mapping = OffsetMapping { 157 + byte_range: range.start..range.start, 158 + char_range: para_start_char..para_start_char, 159 + node_id, 160 + char_offset_in_node: 0, 161 + child_index: Some(0), // position before first child 162 + utf16_len: 0, 163 + }; 164 + self.offset_maps.push(mapping); 165 + 166 + Ok(()) 167 + } 168 + Tag::Paragraph(_) => { 169 + // Handle wrapper before block 170 + self.emit_wrapper_start()?; 171 + 172 + // Record paragraph start for boundary tracking 173 + // BUT skip if inside a list - list owns the paragraph boundary 174 + if self.list_depth == 0 { 175 + self.current_paragraph_start = 176 + Some((self.last_byte_offset, self.last_char_offset)); 177 + } 178 + 179 + let node_id = self.gen_node_id(); 180 + if self.end_newline { 181 + write!(&mut self.writer, "<p id=\"{}\">", node_id)?; 182 + } else { 183 + write!(&mut self.writer, "\n<p id=\"{}\">", node_id)?; 184 + } 185 + self.begin_node(node_id.clone()); 186 + 187 + // Map the start position of the paragraph (before any content) 188 + // This allows cursor to be placed at the very beginning 189 + let para_start_char = self.last_char_offset; 190 + let mapping = OffsetMapping { 191 + byte_range: range.start..range.start, 192 + char_range: para_start_char..para_start_char, 193 + node_id, 194 + char_offset_in_node: 0, 195 + child_index: Some(0), // position before first child 196 + utf16_len: 0, 197 + }; 198 + self.offset_maps.push(mapping); 199 + 200 + // Emit > syntax if we're inside a blockquote 201 + if let Some(bq_range) = self.pending_blockquote_range.take() { 202 + if bq_range.start < bq_range.end { 203 + let raw_text = &self.source[bq_range.clone()]; 204 + if let Some(gt_pos) = raw_text.find('>') { 205 + // Extract > [!NOTE] or just > 206 + let after_gt = &raw_text[gt_pos + 1..]; 207 + let syntax_end = if after_gt.trim_start().starts_with("[!") { 208 + // Find the closing ] 209 + if let Some(close_bracket) = after_gt.find(']') { 210 + gt_pos + 1 + close_bracket + 1 211 + } else { 212 + gt_pos + 1 213 + } 214 + } else { 215 + // Just > and maybe a space 216 + (gt_pos + 1).min(raw_text.len()) 217 + }; 218 + 219 + let syntax = &raw_text[gt_pos..syntax_end]; 220 + let syntax_byte_start = bq_range.start + gt_pos; 221 + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; 222 + } 223 + } 224 + } 225 + Ok(()) 226 + } 227 + Tag::Heading { 228 + level, 229 + id, 230 + classes, 231 + attrs, 232 + } => { 233 + // Emit wrapper if pending (but don't close on heading end - wraps following block too) 234 + self.emit_wrapper_start()?; 235 + 236 + // Record paragraph start for boundary tracking 237 + // Treat headings as paragraph-level blocks 238 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 239 + 240 + if !self.end_newline { 241 + self.write("\n")?; 242 + } 243 + 244 + // Generate node ID for offset tracking 245 + let node_id = self.gen_node_id(); 246 + 247 + self.write("<")?; 248 + write!(&mut self.writer, "{}", level)?; 249 + 250 + // Add our tracking ID as data attribute (preserve user's id if present) 251 + self.write(" data-node-id=\"")?; 252 + self.write(&node_id)?; 253 + self.write("\"")?; 254 + 255 + if let Some(id) = id { 256 + self.write(" id=\"")?; 257 + escape_html(&mut self.writer, &id)?; 258 + self.write("\"")?; 259 + } 260 + if !classes.is_empty() { 261 + self.write(" class=\"")?; 262 + for (i, class) in classes.iter().enumerate() { 263 + if i > 0 { 264 + self.write(" ")?; 265 + } 266 + escape_html(&mut self.writer, class)?; 267 + } 268 + self.write("\"")?; 269 + } 270 + for (attr, value) in attrs { 271 + self.write(" ")?; 272 + escape_html(&mut self.writer, &attr)?; 273 + if let Some(val) = value { 274 + self.write("=\"")?; 275 + escape_html(&mut self.writer, &val)?; 276 + self.write("\"")?; 277 + } else { 278 + self.write("=\"\"")?; 279 + } 280 + } 281 + self.write(">")?; 282 + 283 + // Begin node tracking for offset mapping 284 + self.begin_node(node_id.clone()); 285 + 286 + // Map the start position of the heading (before any content) 287 + // This allows cursor to be placed at the very beginning 288 + let heading_start_char = self.last_char_offset; 289 + let mapping = OffsetMapping { 290 + byte_range: range.start..range.start, 291 + char_range: heading_start_char..heading_start_char, 292 + node_id: node_id.clone(), 293 + char_offset_in_node: 0, 294 + child_index: Some(0), // position before first child 295 + utf16_len: 0, 296 + }; 297 + self.offset_maps.push(mapping); 298 + 299 + // Emit # syntax inside the heading tag 300 + if range.start < range.end { 301 + let raw_text = &self.source[range.clone()]; 302 + let count = level as usize; 303 + let pattern = "#".repeat(count); 304 + 305 + // Find where the # actually starts (might have leading whitespace) 306 + if let Some(hash_pos) = raw_text.find(&pattern) { 307 + // Extract "# " or "## " etc 308 + let syntax_end = (hash_pos + count + 1).min(raw_text.len()); 309 + let syntax = &raw_text[hash_pos..syntax_end]; 310 + let syntax_byte_start = range.start + hash_pos; 311 + 312 + self.emit_inner_syntax(syntax, syntax_byte_start, SyntaxType::Block)?; 313 + } 314 + } 315 + Ok(()) 316 + } 317 + Tag::Table(alignments) => { 318 + if self.render_tables_as_markdown { 319 + // Store start offset and skip HTML rendering 320 + self.table_start_offset = Some(range.start); 321 + self.in_non_writing_block = true; // Suppress content output 322 + Ok(()) 323 + } else { 324 + self.emit_wrapper_start()?; 325 + self.table_alignments = alignments; 326 + self.write("<table>") 327 + } 328 + } 329 + Tag::TableHead => { 330 + if self.render_tables_as_markdown { 331 + Ok(()) // Skip HTML rendering 332 + } else { 333 + self.table_state = TableState::Head; 334 + self.table_cell_index = 0; 335 + self.write("<thead><tr>") 336 + } 337 + } 338 + Tag::TableRow => { 339 + if self.render_tables_as_markdown { 340 + Ok(()) // Skip HTML rendering 341 + } else { 342 + self.table_cell_index = 0; 343 + self.write("<tr>") 344 + } 345 + } 346 + Tag::TableCell => { 347 + if self.render_tables_as_markdown { 348 + Ok(()) // Skip HTML rendering 349 + } else { 350 + match self.table_state { 351 + TableState::Head => self.write("<th")?, 352 + TableState::Body => self.write("<td")?, 353 + } 354 + match self.table_alignments.get(self.table_cell_index) { 355 + Some(&Alignment::Left) => self.write(" style=\"text-align: left\">"), 356 + Some(&Alignment::Center) => self.write(" style=\"text-align: center\">"), 357 + Some(&Alignment::Right) => self.write(" style=\"text-align: right\">"), 358 + _ => self.write(">"), 359 + } 360 + } 361 + } 362 + Tag::BlockQuote(kind) => { 363 + self.emit_wrapper_start()?; 364 + 365 + let class_str = match kind { 366 + None => "", 367 + Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"", 368 + Some(BlockQuoteKind::Tip) => " class=\"markdown-alert-tip\"", 369 + Some(BlockQuoteKind::Important) => " class=\"markdown-alert-important\"", 370 + Some(BlockQuoteKind::Warning) => " class=\"markdown-alert-warning\"", 371 + Some(BlockQuoteKind::Caution) => " class=\"markdown-alert-caution\"", 372 + }; 373 + if self.end_newline { 374 + write!(&mut self.writer, "<blockquote{}>\n", class_str)?; 375 + } else { 376 + write!(&mut self.writer, "\n<blockquote{}>\n", class_str)?; 377 + } 378 + 379 + // Store range for emitting > inside the next paragraph 380 + self.pending_blockquote_range = Some(range); 381 + Ok(()) 382 + } 383 + Tag::CodeBlock(info) => { 384 + self.emit_wrapper_start()?; 385 + 386 + // Track code block as paragraph-level block 387 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 388 + 389 + if !self.end_newline { 390 + self.write_newline()?; 391 + } 392 + 393 + // Generate node ID for code block 394 + let node_id = self.gen_node_id(); 395 + 396 + match info { 397 + CodeBlockKind::Fenced(info) => { 398 + // Emit opening ```language and track both char and byte offsets 399 + if range.start < range.end { 400 + let raw_text = &self.source[range.clone()]; 401 + if let Some(fence_pos) = raw_text.find("```") { 402 + let fence_end = (fence_pos + 3 + info.len()).min(raw_text.len()); 403 + let syntax = &raw_text[fence_pos..fence_end]; 404 + let syntax_char_len = syntax.chars().count() + 1; // +1 for newline 405 + let syntax_byte_len = syntax.len() + 1; // +1 for newline 406 + 407 + let syn_id = self.gen_syn_id(); 408 + let char_start = self.last_char_offset; 409 + let char_end = char_start + syntax_char_len; 410 + 411 + write!( 412 + &mut self.writer, 413 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 414 + syn_id, char_start, char_end 415 + )?; 416 + escape_html(&mut self.writer, syntax)?; 417 + self.write("</span>\n")?; 418 + 419 + // Track opening span index for formatted_range update later 420 + self.code_block_opening_span_idx = Some(self.syntax_spans.len()); 421 + self.code_block_char_start = Some(char_start); 422 + 423 + self.syntax_spans.push(SyntaxSpanInfo { 424 + syn_id, 425 + char_range: char_start..char_end, 426 + syntax_type: SyntaxType::Block, 427 + formatted_range: None, // Will be set in TagEnd::CodeBlock 428 + }); 429 + 430 + self.last_char_offset += syntax_char_len; 431 + self.last_byte_offset = range.start + fence_pos + syntax_byte_len; 432 + } 433 + } 434 + 435 + let lang = info.split(' ').next().unwrap(); 436 + let lang_opt = if lang.is_empty() { 437 + None 438 + } else { 439 + Some(lang.to_string()) 440 + }; 441 + // Start buffering 442 + self.code_buffer = Some((lang_opt, String::new())); 443 + 444 + // Begin node tracking for offset mapping 445 + self.begin_node(node_id); 446 + Ok(()) 447 + } 448 + CodeBlockKind::Indented => { 449 + // Ignore indented code blocks (as per executive decision) 450 + self.code_buffer = Some((None, String::new())); 451 + 452 + // Begin node tracking for offset mapping 453 + self.begin_node(node_id); 454 + Ok(()) 455 + } 456 + } 457 + } 458 + Tag::List(Some(1)) => { 459 + self.emit_wrapper_start()?; 460 + // Track list as paragraph-level block 461 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 462 + self.list_depth += 1; 463 + if self.end_newline { 464 + self.write("<ol>\n") 465 + } else { 466 + self.write("\n<ol>\n") 467 + } 468 + } 469 + Tag::List(Some(start)) => { 470 + self.emit_wrapper_start()?; 471 + // Track list as paragraph-level block 472 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 473 + self.list_depth += 1; 474 + if self.end_newline { 475 + self.write("<ol start=\"")?; 476 + } else { 477 + self.write("\n<ol start=\"")?; 478 + } 479 + write!(&mut self.writer, "{}", start)?; 480 + self.write("\">\n") 481 + } 482 + Tag::List(None) => { 483 + self.emit_wrapper_start()?; 484 + // Track list as paragraph-level block 485 + self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset)); 486 + self.list_depth += 1; 487 + if self.end_newline { 488 + self.write("<ul>\n") 489 + } else { 490 + self.write("\n<ul>\n") 491 + } 492 + } 493 + Tag::Item => { 494 + // Generate node ID for list item 495 + let node_id = self.gen_node_id(); 496 + 497 + if self.end_newline { 498 + write!(&mut self.writer, "<li data-node-id=\"{}\">", node_id)?; 499 + } else { 500 + write!(&mut self.writer, "\n<li data-node-id=\"{}\">", node_id)?; 501 + } 502 + 503 + // Begin node tracking 504 + self.begin_node(node_id); 505 + 506 + // Emit list marker syntax inside the <li> tag and track both offsets 507 + if range.start < range.end { 508 + let raw_text = &self.source[range.clone()]; 509 + 510 + // Try to find the list marker (-, *, or digit.) 511 + let trimmed = raw_text.trim_start(); 512 + let leading_ws_bytes = raw_text.len() - trimmed.len(); 513 + let leading_ws_chars = raw_text.chars().count() - trimmed.chars().count(); 514 + 515 + if let Some(marker) = trimmed.chars().next() { 516 + if marker == '-' || marker == '*' { 517 + // Unordered list: extract "- " or "* " 518 + let marker_end = trimmed 519 + .find(|c: char| c != '-' && c != '*') 520 + .map(|pos| pos + 1) 521 + .unwrap_or(1); 522 + let syntax = &trimmed[..marker_end.min(trimmed.len())]; 523 + let char_start = self.last_char_offset; 524 + let syntax_char_len = leading_ws_chars + syntax.chars().count(); 525 + let syntax_byte_len = leading_ws_bytes + syntax.len(); 526 + let char_end = char_start + syntax_char_len; 527 + 528 + let syn_id = self.gen_syn_id(); 529 + write!( 530 + &mut self.writer, 531 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 532 + syn_id, char_start, char_end 533 + )?; 534 + escape_html(&mut self.writer, syntax)?; 535 + self.write("</span>")?; 536 + 537 + self.syntax_spans.push(SyntaxSpanInfo { 538 + syn_id, 539 + char_range: char_start..char_end, 540 + syntax_type: SyntaxType::Block, 541 + formatted_range: None, 542 + }); 543 + 544 + // Record offset mapping for cursor positioning 545 + self.record_mapping( 546 + range.start..range.start + syntax_byte_len, 547 + char_start..char_end, 548 + ); 549 + self.last_char_offset = char_end; 550 + self.last_byte_offset = range.start + syntax_byte_len; 551 + } else if marker.is_ascii_digit() { 552 + // Ordered list: extract "1. " or similar (including trailing space) 553 + if let Some(dot_pos) = trimmed.find('.') { 554 + let syntax_end = (dot_pos + 2).min(trimmed.len()); 555 + let syntax = &trimmed[..syntax_end]; 556 + let char_start = self.last_char_offset; 557 + let syntax_char_len = leading_ws_chars + syntax.chars().count(); 558 + let syntax_byte_len = leading_ws_bytes + syntax.len(); 559 + let char_end = char_start + syntax_char_len; 560 + 561 + let syn_id = self.gen_syn_id(); 562 + write!( 563 + &mut self.writer, 564 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 565 + syn_id, char_start, char_end 566 + )?; 567 + escape_html(&mut self.writer, syntax)?; 568 + self.write("</span>")?; 569 + 570 + self.syntax_spans.push(SyntaxSpanInfo { 571 + syn_id, 572 + char_range: char_start..char_end, 573 + syntax_type: SyntaxType::Block, 574 + formatted_range: None, 575 + }); 576 + 577 + // Record offset mapping for cursor positioning 578 + self.record_mapping( 579 + range.start..range.start + syntax_byte_len, 580 + char_start..char_end, 581 + ); 582 + self.last_char_offset = char_end; 583 + self.last_byte_offset = range.start + syntax_byte_len; 584 + } 585 + } 586 + } 587 + } 588 + Ok(()) 589 + } 590 + Tag::DefinitionList => { 591 + self.emit_wrapper_start()?; 592 + if self.end_newline { 593 + self.write("<dl>\n") 594 + } else { 595 + self.write("\n<dl>\n") 596 + } 597 + } 598 + Tag::DefinitionListTitle => { 599 + let node_id = self.gen_node_id(); 600 + 601 + if self.end_newline { 602 + write!(&mut self.writer, "<dt data-node-id=\"{}\">", node_id)?; 603 + } else { 604 + write!(&mut self.writer, "\n<dt data-node-id=\"{}\">", node_id)?; 605 + } 606 + 607 + self.begin_node(node_id); 608 + Ok(()) 609 + } 610 + Tag::DefinitionListDefinition => { 611 + let node_id = self.gen_node_id(); 612 + 613 + if self.end_newline { 614 + write!(&mut self.writer, "<dd data-node-id=\"{}\">", node_id)?; 615 + } else { 616 + write!(&mut self.writer, "\n<dd data-node-id=\"{}\">", node_id)?; 617 + } 618 + 619 + self.begin_node(node_id); 620 + Ok(()) 621 + } 622 + Tag::Subscript => self.write("<sub>"), 623 + Tag::Superscript => self.write("<sup>"), 624 + Tag::Emphasis => self.write("<em>"), 625 + Tag::Strong => self.write("<strong>"), 626 + Tag::Strikethrough => self.write("<s>"), 627 + Tag::Link { 628 + link_type: LinkType::Email, 629 + dest_url, 630 + title, 631 + .. 632 + } => { 633 + self.write("<a href=\"mailto:")?; 634 + escape_href(&mut self.writer, &dest_url)?; 635 + if !title.is_empty() { 636 + self.write("\" title=\"")?; 637 + escape_html(&mut self.writer, &title)?; 638 + } 639 + self.write("\">") 640 + } 641 + Tag::Link { 642 + link_type, 643 + dest_url, 644 + title, 645 + .. 646 + } => { 647 + // Collect refs for later resolution 648 + let url = dest_url.as_ref(); 649 + if matches!(link_type, LinkType::WikiLink { .. }) { 650 + let (target, fragment) = weaver_common::EntryIndex::parse_wikilink(url); 651 + self.ref_collector.add_wikilink(target, fragment, None); 652 + } else if url.starts_with("at://") { 653 + self.ref_collector.add_at_link(url); 654 + } 655 + 656 + // Determine link validity class for wikilinks 657 + let validity_class = if matches!(link_type, LinkType::WikiLink { .. }) { 658 + if let Some(index) = &self.entry_index { 659 + if index.resolve(dest_url.as_ref()).is_some() { 660 + " link-valid" 661 + } else { 662 + " link-broken" 663 + } 664 + } else { 665 + "" 666 + } 667 + } else { 668 + "" 669 + }; 670 + 671 + self.write("<a class=\"link")?; 672 + self.write(validity_class)?; 673 + self.write("\" href=\"")?; 674 + escape_href(&mut self.writer, &dest_url)?; 675 + if !title.is_empty() { 676 + self.write("\" title=\"")?; 677 + escape_html(&mut self.writer, &title)?; 678 + } 679 + self.write("\">") 680 + } 681 + Tag::Image { 682 + link_type, 683 + dest_url, 684 + title, 685 + id, 686 + attrs, 687 + } => { 688 + // Check if this is actually an AT embed disguised as a wikilink image 689 + // (markdown-weaver parses ![[at://...]] as Image with WikiLink link_type) 690 + let url = dest_url.as_ref(); 691 + if matches!(link_type, LinkType::WikiLink { .. }) 692 + && (url.starts_with("at://") || url.starts_with("did:")) 693 + { 694 + return self.write_embed( 695 + range, 696 + EmbedType::Other, // AT embeds - disambiguated via NSID later 697 + dest_url, 698 + title, 699 + id, 700 + attrs, 701 + ); 702 + } 703 + 704 + // Image rendering: all syntax elements share one syn_id for visibility toggling 705 + // Structure: ![ alt text ](url) <img> cursor-landing 706 + let raw_text = &self.source[range.clone()]; 707 + let syn_id = self.gen_syn_id(); 708 + let opening_char_start = self.last_char_offset; 709 + 710 + // Find the alt text and closing syntax positions 711 + let paren_pos = raw_text.rfind("](").unwrap_or(raw_text.len()); 712 + let alt_text = if raw_text.starts_with("![") && paren_pos > 2 { 713 + &raw_text[2..paren_pos] 714 + } else { 715 + "" 716 + }; 717 + let closing_syntax = if paren_pos < raw_text.len() { 718 + &raw_text[paren_pos..] 719 + } else { 720 + "" 721 + }; 722 + 723 + // Calculate char positions 724 + let alt_char_len = alt_text.chars().count(); 725 + let closing_char_len = closing_syntax.chars().count(); 726 + let opening_char_end = opening_char_start + 2; // "![" 727 + let alt_char_start = opening_char_end; 728 + let alt_char_end = alt_char_start + alt_char_len; 729 + let closing_char_start = alt_char_end; 730 + let closing_char_end = closing_char_start + closing_char_len; 731 + let formatted_range = opening_char_start..closing_char_end; 732 + 733 + // 1. Emit opening ![ syntax span 734 + if raw_text.starts_with("![") { 735 + write!( 736 + &mut self.writer, 737 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">![</span>", 738 + syn_id, opening_char_start, opening_char_end 739 + )?; 740 + 741 + self.syntax_spans.push(SyntaxSpanInfo { 742 + syn_id: syn_id.clone(), 743 + char_range: opening_char_start..opening_char_end, 744 + syntax_type: SyntaxType::Inline, 745 + formatted_range: Some(formatted_range.clone()), 746 + }); 747 + 748 + // Record offset mapping for ![ 749 + self.record_mapping( 750 + range.start..range.start + 2, 751 + opening_char_start..opening_char_end, 752 + ); 753 + } 754 + 755 + // 2. Emit alt text span (same syn_id, editable when visible) 756 + if !alt_text.is_empty() { 757 + write!( 758 + &mut self.writer, 759 + "<span class=\"image-alt\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">", 760 + syn_id, alt_char_start, alt_char_end 761 + )?; 762 + escape_html(&mut self.writer, alt_text)?; 763 + self.write("</span>")?; 764 + 765 + self.syntax_spans.push(SyntaxSpanInfo { 766 + syn_id: syn_id.clone(), 767 + char_range: alt_char_start..alt_char_end, 768 + syntax_type: SyntaxType::Inline, 769 + formatted_range: Some(formatted_range.clone()), 770 + }); 771 + 772 + // Record offset mapping for alt text 773 + self.record_mapping( 774 + range.start + 2..range.start + 2 + alt_text.len(), 775 + alt_char_start..alt_char_end, 776 + ); 777 + } 778 + 779 + // 3. Emit closing ](url) syntax span 780 + if !closing_syntax.is_empty() { 781 + write!( 782 + &mut self.writer, 783 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 784 + syn_id, closing_char_start, closing_char_end 785 + )?; 786 + escape_html(&mut self.writer, closing_syntax)?; 787 + self.write("</span>")?; 788 + 789 + self.syntax_spans.push(SyntaxSpanInfo { 790 + syn_id: syn_id.clone(), 791 + char_range: closing_char_start..closing_char_end, 792 + syntax_type: SyntaxType::Inline, 793 + formatted_range: Some(formatted_range.clone()), 794 + }); 795 + 796 + // Record offset mapping for ](url) 797 + self.record_mapping( 798 + range.start + paren_pos..range.end, 799 + closing_char_start..closing_char_end, 800 + ); 801 + } 802 + 803 + // 4. Emit <img> element (no syn_id - always visible) 804 + self.write("<img src=\"")?; 805 + let resolved_url = self 806 + .image_resolver 807 + .as_ref() 808 + .and_then(|r| r.resolve_image_url(&dest_url)); 809 + if let Some(ref cdn_url) = resolved_url { 810 + escape_href(&mut self.writer, cdn_url)?; 811 + } else { 812 + escape_href(&mut self.writer, &dest_url)?; 813 + } 814 + self.write("\" alt=\"")?; 815 + escape_html(&mut self.writer, alt_text)?; 816 + self.write("\"")?; 817 + if !title.is_empty() { 818 + self.write(" title=\"")?; 819 + escape_html(&mut self.writer, &title)?; 820 + self.write("\"")?; 821 + } 822 + if let Some(attrs) = attrs { 823 + if !attrs.classes.is_empty() { 824 + self.write(" class=\"")?; 825 + for (i, class) in attrs.classes.iter().enumerate() { 826 + if i > 0 { 827 + self.write(" ")?; 828 + } 829 + escape_html(&mut self.writer, class)?; 830 + } 831 + self.write("\"")?; 832 + } 833 + for (attr, value) in &attrs.attrs { 834 + self.write(" ")?; 835 + escape_html(&mut self.writer, attr)?; 836 + self.write("=\"")?; 837 + escape_html(&mut self.writer, value)?; 838 + self.write("\"")?; 839 + } 840 + } 841 + self.write(" />")?; 842 + 843 + // Consume the text events for alt (they're still in the iterator) 844 + // Use consume_until_end() since we already wrote alt text from source 845 + self.consume_until_end(); 846 + 847 + // Update offsets 848 + self.last_char_offset = closing_char_end; 849 + self.last_byte_offset = range.end; 850 + 851 + Ok(()) 852 + } 853 + Tag::Embed { 854 + embed_type, 855 + dest_url, 856 + title, 857 + id, 858 + attrs, 859 + } => self.write_embed(range, embed_type, dest_url, title, id, attrs), 860 + Tag::WeaverBlock(_, attrs) => { 861 + self.in_non_writing_block = true; 862 + self.weaver_block_buffer.clear(); 863 + self.weaver_block_char_start = Some(self.last_char_offset); 864 + // Store attrs from Start tag, will merge with parsed text on End 865 + if !attrs.classes.is_empty() || !attrs.attrs.is_empty() { 866 + self.pending_block_attrs = Some(attrs.into_static()); 867 + } 868 + Ok(()) 869 + } 870 + Tag::FootnoteDefinition(name) => { 871 + // Emit the [^name]: prefix as a hideable syntax span 872 + // The source should have "[^name]: " at the start 873 + let prefix = format!("[^{}]: ", name); 874 + let char_start = self.last_char_offset; 875 + let prefix_char_len = prefix.chars().count(); 876 + let char_end = char_start + prefix_char_len; 877 + let syn_id = self.gen_syn_id(); 878 + 879 + if !self.end_newline { 880 + self.write("\n")?; 881 + } 882 + 883 + write!( 884 + &mut self.writer, 885 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 886 + syn_id, char_start, char_end 887 + )?; 888 + escape_html(&mut self.writer, &prefix)?; 889 + self.write("</span>")?; 890 + 891 + // Track this span for linking with the footnote reference 892 + let def_span_index = self.syntax_spans.len(); 893 + self.syntax_spans.push(SyntaxSpanInfo { 894 + syn_id, 895 + char_range: char_start..char_end, 896 + syntax_type: SyntaxType::Block, 897 + formatted_range: None, // Set at FootnoteDefinition end 898 + }); 899 + 900 + // Store the definition info for linking at end 901 + self.current_footnote_def = Some((name.to_string(), def_span_index, char_start)); 902 + 903 + // Record offset mapping for the syntax span 904 + self.record_mapping( 905 + range.start..range.start + prefix.len(), 906 + char_start..char_end, 907 + ); 908 + 909 + // Update tracking for the prefix 910 + self.last_char_offset = char_end; 911 + self.last_byte_offset = range.start + prefix.len(); 912 + 913 + // Emit the definition container 914 + write!( 915 + &mut self.writer, 916 + "<div class=\"footnote-definition\" id=\"fn-{}\">", 917 + name 918 + )?; 919 + 920 + // Get/create footnote number for the label 921 + let len = self.numbers.len() + 1; 922 + let number = *self.numbers.entry(name.to_string()).or_insert(len); 923 + write!( 924 + &mut self.writer, 925 + "<sup class=\"footnote-definition-label\">{}</sup>", 926 + number 927 + )?; 928 + 929 + Ok(()) 930 + } 931 + Tag::MetadataBlock(_) => { 932 + self.in_non_writing_block = true; 933 + Ok(()) 934 + } 935 + } 936 + } 937 + 938 + pub(crate) fn end_tag( 939 + &mut self, 940 + tag: markdown_weaver::TagEnd, 941 + range: Range<usize>, 942 + ) -> Result<(), fmt::Error> { 943 + use markdown_weaver::TagEnd; 944 + 945 + // Emit tag HTML first 946 + let result = match tag { 947 + TagEnd::HtmlBlock => { 948 + // Capture paragraph boundary info BEFORE writing closing HTML 949 + // Skip if inside a list - list owns the paragraph boundary 950 + let para_boundary = if self.list_depth == 0 { 951 + self.current_paragraph_start 952 + .take() 953 + .map(|(byte_start, char_start)| { 954 + ( 955 + byte_start..self.last_byte_offset, 956 + char_start..self.last_char_offset, 957 + ) 958 + }) 959 + } else { 960 + None 961 + }; 962 + 963 + // Write closing HTML to current segment 964 + self.end_node(); 965 + self.write("</p>\n")?; 966 + 967 + // Now finalize paragraph (starts new segment) 968 + if let Some((byte_range, char_range)) = para_boundary { 969 + self.finalize_paragraph(byte_range, char_range); 970 + } 971 + Ok(()) 972 + } 973 + TagEnd::Paragraph(_) => { 974 + // Capture paragraph boundary info BEFORE writing closing HTML 975 + // Skip if inside a list - list owns the paragraph boundary 976 + let para_boundary = if self.list_depth == 0 { 977 + self.current_paragraph_start 978 + .take() 979 + .map(|(byte_start, char_start)| { 980 + ( 981 + byte_start..self.last_byte_offset, 982 + char_start..self.last_char_offset, 983 + ) 984 + }) 985 + } else { 986 + None 987 + }; 988 + 989 + // Write closing HTML to current segment 990 + self.end_node(); 991 + self.write("</p>\n")?; 992 + self.close_wrapper()?; 993 + 994 + // Now finalize paragraph (starts new segment) 995 + if let Some((byte_range, char_range)) = para_boundary { 996 + self.finalize_paragraph(byte_range, char_range); 997 + } 998 + Ok(()) 999 + } 1000 + TagEnd::Heading(level) => { 1001 + // Capture paragraph boundary info BEFORE writing closing HTML 1002 + let para_boundary = 1003 + self.current_paragraph_start 1004 + .take() 1005 + .map(|(byte_start, char_start)| { 1006 + ( 1007 + byte_start..self.last_byte_offset, 1008 + char_start..self.last_char_offset, 1009 + ) 1010 + }); 1011 + 1012 + // Write closing HTML to current segment 1013 + self.end_node(); 1014 + self.write("</")?; 1015 + write!(&mut self.writer, "{}", level)?; 1016 + self.write(">\n")?; 1017 + // Note: Don't close wrapper here - headings typically go with following block 1018 + 1019 + // Now finalize paragraph (starts new segment) 1020 + if let Some((byte_range, char_range)) = para_boundary { 1021 + self.finalize_paragraph(byte_range, char_range); 1022 + } 1023 + Ok(()) 1024 + } 1025 + TagEnd::Table => { 1026 + if self.render_tables_as_markdown { 1027 + // Emit the raw markdown table 1028 + if let Some(start) = self.table_start_offset.take() { 1029 + let table_text = &self.source[start..range.end]; 1030 + self.in_non_writing_block = false; 1031 + 1032 + // Wrap in a pre or div for styling 1033 + self.write("<pre class=\"table-markdown\">")?; 1034 + escape_html(&mut self.writer, table_text)?; 1035 + self.write("</pre>\n")?; 1036 + } 1037 + Ok(()) 1038 + } else { 1039 + self.write("</tbody></table>\n") 1040 + } 1041 + } 1042 + TagEnd::TableHead => { 1043 + if self.render_tables_as_markdown { 1044 + Ok(()) // Skip HTML rendering 1045 + } else { 1046 + self.write("</tr></thead><tbody>\n")?; 1047 + self.table_state = TableState::Body; 1048 + Ok(()) 1049 + } 1050 + } 1051 + TagEnd::TableRow => { 1052 + if self.render_tables_as_markdown { 1053 + Ok(()) // Skip HTML rendering 1054 + } else { 1055 + self.write("</tr>\n") 1056 + } 1057 + } 1058 + TagEnd::TableCell => { 1059 + if self.render_tables_as_markdown { 1060 + Ok(()) // Skip HTML rendering 1061 + } else { 1062 + match self.table_state { 1063 + TableState::Head => self.write("</th>")?, 1064 + TableState::Body => self.write("</td>")?, 1065 + } 1066 + self.table_cell_index += 1; 1067 + Ok(()) 1068 + } 1069 + } 1070 + TagEnd::BlockQuote(_) => { 1071 + // If pending_blockquote_range is still set, the blockquote was empty 1072 + // (no paragraph inside). Emit the > as its own minimal paragraph. 1073 + let mut para_boundary = None; 1074 + if let Some(bq_range) = self.pending_blockquote_range.take() { 1075 + if bq_range.start < bq_range.end { 1076 + let raw_text = &self.source[bq_range.clone()]; 1077 + if let Some(gt_pos) = raw_text.find('>') { 1078 + let para_byte_start = bq_range.start + gt_pos; 1079 + let para_char_start = self.last_char_offset; 1080 + 1081 + // Create a minimal paragraph for the empty blockquote 1082 + let node_id = self.gen_node_id(); 1083 + write!(&mut self.writer, "<div id=\"{}\"", node_id)?; 1084 + 1085 + // Record start-of-node mapping for cursor positioning 1086 + self.offset_maps.push(OffsetMapping { 1087 + byte_range: para_byte_start..para_byte_start, 1088 + char_range: para_char_start..para_char_start, 1089 + node_id: node_id.clone(), 1090 + char_offset_in_node: gt_pos, 1091 + child_index: Some(0), 1092 + utf16_len: 0, 1093 + }); 1094 + 1095 + // Emit the > as block syntax 1096 + let syntax = &raw_text[gt_pos..gt_pos + 1]; 1097 + self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?; 1098 + 1099 + self.write("</div>\n")?; 1100 + self.end_node(); 1101 + 1102 + // Capture paragraph boundary for later finalization 1103 + let byte_range = para_byte_start..bq_range.end; 1104 + let char_range = para_char_start..self.last_char_offset; 1105 + para_boundary = Some((byte_range, char_range)); 1106 + } 1107 + } 1108 + } 1109 + self.write("</blockquote>\n")?; 1110 + self.close_wrapper()?; 1111 + 1112 + // Now finalize paragraph if we had one 1113 + if let Some((byte_range, char_range)) = para_boundary { 1114 + self.finalize_paragraph(byte_range, char_range); 1115 + } 1116 + Ok(()) 1117 + } 1118 + TagEnd::CodeBlock => { 1119 + use std::sync::LazyLock; 1120 + use syntect::parsing::SyntaxSet; 1121 + static SYNTAX_SET: LazyLock<SyntaxSet> = 1122 + LazyLock::new(|| SyntaxSet::load_defaults_newlines()); 1123 + 1124 + if let Some((lang, buffer)) = self.code_buffer.take() { 1125 + // Create offset mapping for code block content if we tracked ranges 1126 + if let (Some(code_byte_range), Some(code_char_range)) = ( 1127 + self.code_buffer_byte_range.take(), 1128 + self.code_buffer_char_range.take(), 1129 + ) { 1130 + // Record mapping before writing HTML 1131 + // (current_node_id should be set by start_tag for CodeBlock) 1132 + self.record_mapping(code_byte_range, code_char_range); 1133 + } 1134 + 1135 + // Get node_id for data-node-id attribute (needed for cursor positioning) 1136 + let node_id = self.current_node_id.clone(); 1137 + 1138 + if let Some(ref lang_str) = lang { 1139 + // Use a temporary String buffer for syntect 1140 + let mut temp_output = String::new(); 1141 + match weaver_renderer::code_pretty::highlight( 1142 + &SYNTAX_SET, 1143 + Some(lang_str), 1144 + &buffer, 1145 + &mut temp_output, 1146 + ) { 1147 + Ok(_) => { 1148 + // Inject data-node-id into the <pre> tag for cursor positioning 1149 + if let Some(ref nid) = node_id { 1150 + let injected = temp_output.replacen( 1151 + "<pre>", 1152 + &format!("<pre data-node-id=\"{}\">", nid), 1153 + 1, 1154 + ); 1155 + self.write(&injected)?; 1156 + } else { 1157 + self.write(&temp_output)?; 1158 + } 1159 + } 1160 + Err(_) => { 1161 + // Fallback to plain code block 1162 + if let Some(ref nid) = node_id { 1163 + write!( 1164 + &mut self.writer, 1165 + "<pre data-node-id=\"{}\"><code class=\"language-", 1166 + nid 1167 + )?; 1168 + } else { 1169 + self.write("<pre><code class=\"language-")?; 1170 + } 1171 + escape_html(&mut self.writer, lang_str)?; 1172 + self.write("\">")?; 1173 + escape_html_body_text(&mut self.writer, &buffer)?; 1174 + self.write("</code></pre>\n")?; 1175 + } 1176 + } 1177 + } else { 1178 + if let Some(ref nid) = node_id { 1179 + write!(&mut self.writer, "<pre data-node-id=\"{}\"><code>", nid)?; 1180 + } else { 1181 + self.write("<pre><code>")?; 1182 + } 1183 + escape_html_body_text(&mut self.writer, &buffer)?; 1184 + self.write("</code></pre>\n")?; 1185 + } 1186 + 1187 + // End node tracking 1188 + self.end_node(); 1189 + } else { 1190 + self.write("</code></pre>\n")?; 1191 + } 1192 + 1193 + // Emit closing ``` (emit_gap_before is skipped while buffering) 1194 + // Track the opening span index and char start before we potentially clear them 1195 + let opening_span_idx = self.code_block_opening_span_idx.take(); 1196 + let code_block_start = self.code_block_char_start.take(); 1197 + 1198 + if range.start < range.end { 1199 + let raw_text = &self.source[range.clone()]; 1200 + if let Some(fence_line) = raw_text.lines().last() { 1201 + if fence_line.trim().starts_with("```") { 1202 + let fence = fence_line.trim(); 1203 + let fence_char_len = fence.chars().count(); 1204 + 1205 + let syn_id = self.gen_syn_id(); 1206 + let char_start = self.last_char_offset; 1207 + let char_end = char_start + fence_char_len; 1208 + 1209 + write!( 1210 + &mut self.writer, 1211 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1212 + syn_id, char_start, char_end 1213 + )?; 1214 + escape_html(&mut self.writer, fence)?; 1215 + self.write("</span>")?; 1216 + 1217 + self.last_char_offset += fence_char_len; 1218 + self.last_byte_offset += fence.len(); 1219 + 1220 + // Compute formatted_range for entire code block (opening fence to closing fence) 1221 + let formatted_range = 1222 + code_block_start.map(|start| start..self.last_char_offset); 1223 + 1224 + // Update opening fence span with formatted_range 1225 + if let (Some(idx), Some(fr)) = 1226 + (opening_span_idx, formatted_range.as_ref()) 1227 + { 1228 + if let Some(span) = self.syntax_spans.get_mut(idx) { 1229 + span.formatted_range = Some(fr.clone()); 1230 + } 1231 + } 1232 + 1233 + // Push closing fence span with formatted_range 1234 + self.syntax_spans.push(SyntaxSpanInfo { 1235 + syn_id, 1236 + char_range: char_start..char_end, 1237 + syntax_type: SyntaxType::Block, 1238 + formatted_range, 1239 + }); 1240 + } 1241 + } 1242 + } 1243 + 1244 + // Finalize code block paragraph 1245 + if let Some((byte_start, char_start)) = self.current_paragraph_start.take() { 1246 + let byte_range = byte_start..self.last_byte_offset; 1247 + let char_range = char_start..self.last_char_offset; 1248 + self.finalize_paragraph(byte_range, char_range); 1249 + } 1250 + 1251 + Ok(()) 1252 + } 1253 + TagEnd::List(true) => { 1254 + self.list_depth = self.list_depth.saturating_sub(1); 1255 + // Capture paragraph boundary BEFORE writing closing HTML 1256 + let para_boundary = 1257 + self.current_paragraph_start 1258 + .take() 1259 + .map(|(byte_start, char_start)| { 1260 + ( 1261 + byte_start..self.last_byte_offset, 1262 + char_start..self.last_char_offset, 1263 + ) 1264 + }); 1265 + 1266 + self.write("</ol>\n")?; 1267 + self.close_wrapper()?; 1268 + 1269 + // Finalize paragraph after closing HTML 1270 + if let Some((byte_range, char_range)) = para_boundary { 1271 + self.finalize_paragraph(byte_range, char_range); 1272 + } 1273 + Ok(()) 1274 + } 1275 + TagEnd::List(false) => { 1276 + self.list_depth = self.list_depth.saturating_sub(1); 1277 + // Capture paragraph boundary BEFORE writing closing HTML 1278 + let para_boundary = 1279 + self.current_paragraph_start 1280 + .take() 1281 + .map(|(byte_start, char_start)| { 1282 + ( 1283 + byte_start..self.last_byte_offset, 1284 + char_start..self.last_char_offset, 1285 + ) 1286 + }); 1287 + 1288 + self.write("</ul>\n")?; 1289 + self.close_wrapper()?; 1290 + 1291 + // Finalize paragraph after closing HTML 1292 + if let Some((byte_range, char_range)) = para_boundary { 1293 + self.finalize_paragraph(byte_range, char_range); 1294 + } 1295 + Ok(()) 1296 + } 1297 + TagEnd::Item => { 1298 + self.end_node(); 1299 + self.write("</li>\n") 1300 + } 1301 + TagEnd::DefinitionList => { 1302 + self.write("</dl>\n")?; 1303 + self.close_wrapper() 1304 + } 1305 + TagEnd::DefinitionListTitle => { 1306 + self.end_node(); 1307 + self.write("</dt>\n") 1308 + } 1309 + TagEnd::DefinitionListDefinition => { 1310 + self.end_node(); 1311 + self.write("</dd>\n") 1312 + } 1313 + TagEnd::Emphasis => { 1314 + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 1315 + self.write("</em>")?; 1316 + self.emit_gap_before(range.end)?; 1317 + self.finalize_paired_inline_format(); 1318 + Ok(()) 1319 + } 1320 + TagEnd::Superscript => self.write("</sup>"), 1321 + TagEnd::Subscript => self.write("</sub>"), 1322 + TagEnd::Strong => { 1323 + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 1324 + self.write("</strong>")?; 1325 + self.emit_gap_before(range.end)?; 1326 + self.finalize_paired_inline_format(); 1327 + Ok(()) 1328 + } 1329 + TagEnd::Strikethrough => { 1330 + // Write closing tag FIRST, then emit closing syntax OUTSIDE the tag 1331 + self.write("</s>")?; 1332 + self.emit_gap_before(range.end)?; 1333 + self.finalize_paired_inline_format(); 1334 + Ok(()) 1335 + } 1336 + TagEnd::Link => { 1337 + self.write("</a>")?; 1338 + // Check if this is a wiki link (ends with ]]) vs regular link (ends with )) 1339 + let raw_text = &self.source[range.clone()]; 1340 + if raw_text.ends_with("]]") { 1341 + // WikiLink: emit ]] as closing syntax 1342 + let syn_id = self.gen_syn_id(); 1343 + let char_start = self.last_char_offset; 1344 + let char_end = char_start + 2; 1345 + 1346 + write!( 1347 + &mut self.writer, 1348 + "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">]]</span>", 1349 + syn_id, char_start, char_end 1350 + )?; 1351 + 1352 + self.syntax_spans.push(SyntaxSpanInfo { 1353 + syn_id, 1354 + char_range: char_start..char_end, 1355 + syntax_type: SyntaxType::Inline, 1356 + formatted_range: None, // Will be set by finalize 1357 + }); 1358 + 1359 + self.last_char_offset = char_end; 1360 + self.last_byte_offset = range.end; 1361 + } else { 1362 + self.emit_gap_before(range.end)?; 1363 + } 1364 + self.finalize_paired_inline_format(); 1365 + Ok(()) 1366 + } 1367 + TagEnd::Image => Ok(()), // No-op: raw_text() already consumed the End(Image) event 1368 + TagEnd::Embed => Ok(()), 1369 + TagEnd::WeaverBlock(_) => { 1370 + self.in_non_writing_block = false; 1371 + 1372 + // Emit the { content } as a hideable syntax span 1373 + if let Some(char_start) = self.weaver_block_char_start.take() { 1374 + // Build the full syntax text: { buffered_content } 1375 + let syntax_text = format!("{{{}}}", self.weaver_block_buffer); 1376 + let syntax_char_len = syntax_text.chars().count(); 1377 + let char_end = char_start + syntax_char_len; 1378 + 1379 + let syn_id = self.gen_syn_id(); 1380 + 1381 + write!( 1382 + &mut self.writer, 1383 + "<span class=\"md-syntax-block\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">", 1384 + syn_id, char_start, char_end 1385 + )?; 1386 + escape_html(&mut self.writer, &syntax_text)?; 1387 + self.write("</span>")?; 1388 + 1389 + // Track the syntax span 1390 + self.syntax_spans.push(SyntaxSpanInfo { 1391 + syn_id, 1392 + char_range: char_start..char_end, 1393 + syntax_type: SyntaxType::Block, 1394 + formatted_range: None, 1395 + }); 1396 + 1397 + // Record offset mapping for the syntax span 1398 + self.record_mapping(range.clone(), char_start..char_end); 1399 + 1400 + // Update tracking 1401 + self.last_char_offset = char_end; 1402 + self.last_byte_offset = range.end; 1403 + } 1404 + 1405 + // Parse the buffered text for attrs and store for next block 1406 + if !self.weaver_block_buffer.is_empty() { 1407 + let parsed = Self::parse_weaver_attrs(&self.weaver_block_buffer); 1408 + self.weaver_block_buffer.clear(); 1409 + // Merge with any existing pending attrs or set new 1410 + if let Some(ref mut existing) = self.pending_block_attrs { 1411 + existing.classes.extend(parsed.classes); 1412 + existing.attrs.extend(parsed.attrs); 1413 + } else { 1414 + self.pending_block_attrs = Some(parsed); 1415 + } 1416 + } 1417 + 1418 + Ok(()) 1419 + } 1420 + TagEnd::FootnoteDefinition => { 1421 + self.write("</div>\n")?; 1422 + 1423 + // Link the footnote definition span with its reference span 1424 + if let Some((name, def_span_index, _def_char_start)) = 1425 + self.current_footnote_def.take() 1426 + { 1427 + let def_char_end = self.last_char_offset; 1428 + 1429 + // Look up the reference span 1430 + if let Some(&(ref_span_index, ref_char_start)) = 1431 + self.footnote_ref_spans.get(&name) 1432 + { 1433 + // Create formatted_range spanning from ref start to def end 1434 + let formatted_range = ref_char_start..def_char_end; 1435 + 1436 + // Update both spans with the same formatted_range 1437 + // so they show/hide together based on cursor proximity 1438 + if let Some(ref_span) = self.syntax_spans.get_mut(ref_span_index) { 1439 + ref_span.formatted_range = Some(formatted_range.clone()); 1440 + } 1441 + if let Some(def_span) = self.syntax_spans.get_mut(def_span_index) { 1442 + def_span.formatted_range = Some(formatted_range); 1443 + } 1444 + } 1445 + } 1446 + 1447 + Ok(()) 1448 + } 1449 + TagEnd::MetadataBlock(_) => { 1450 + self.in_non_writing_block = false; 1451 + Ok(()) 1452 + } 1453 + }; 1454 + 1455 + result?; 1456 + 1457 + // Note: Closing syntax for inline formatting tags (Strong, Emphasis, Strikethrough) 1458 + // is handled INSIDE their respective match arms above, AFTER writing the closing HTML. 1459 + // This ensures the closing syntax span appears OUTSIDE the formatted element. 1460 + // Other End events have their closing syntax emitted by emit_gap_before() in the main loop. 1461 + 1462 + Ok(()) 1463 + } 1464 + }
+21 -8
crates/weaver-app/src/components/entry.rs
··· 212 212 213 213 tracing::info!("Entry: {book_title} - {title}"); 214 214 215 - let prev_entry = book_entry_view().prev.clone(); 216 - let next_entry = book_entry_view().next.clone(); 217 - 218 215 rsx! { 219 216 EntryOgMeta { 220 217 title: title.to_string(), ··· 229 226 div { class: "entry-page", 230 227 // Header: nav prev + metadata + nav next 231 228 header { class: "entry-header", 232 - if let Some(ref prev) = prev_entry { 229 + if let Some(ref prev) = book_entry_view().prev { 233 230 NavButton { 234 231 direction: "prev", 235 232 entry: prev.entry.clone(), 236 233 ident: ident(), 237 234 book_title: book_title() 238 235 } 236 + } else { 237 + div { class: "nav-placeholder" } 239 238 } 240 239 241 240 { ··· 253 252 } 254 253 } 255 254 256 - if let Some(ref next) = next_entry { 255 + if let Some(ref next) = book_entry_view().next { 257 256 NavButton { 258 257 direction: "next", 259 258 entry: next.entry.clone(), 260 259 ident: ident(), 261 260 book_title: book_title() 262 261 } 262 + } else { 263 + div { class: "nav-placeholder" } 263 264 } 264 265 } 265 266 ··· 275 276 276 277 // Footer navigation 277 278 footer { class: "entry-footer-nav", 278 - if let Some(ref prev) = prev_entry { 279 + if let Some(ref prev) = book_entry_view().prev { 279 280 NavButton { 280 281 direction: "prev", 281 282 entry: prev.entry.clone(), ··· 284 285 } 285 286 } 286 287 287 - if let Some(ref next) = next_entry { 288 + if let Some(ref next) = book_entry_view().next { 288 289 NavButton { 289 290 direction: "next", 290 291 entry: next.entry.clone(), ··· 726 727 727 728 /// Render some text as markdown. 728 729 pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element { 729 - let (_res, processed) = crate::data::use_rendered_markdown(props.content, props.ident); 730 + let (mut _res, processed) = crate::data::use_rendered_markdown(props.content, props.ident); 731 + 732 + // Track entry title to detect content change and restart resource 733 + let mut last_title = use_signal(|| (props.content)().title.to_string()); 734 + let current_title = (props.content)().title.to_string(); 735 + if current_title != last_title() { 736 + #[cfg(feature = "fullstack-server")] 737 + if let Ok(ref mut r) = _res { 738 + r.restart(); 739 + } 740 + last_title.set(current_title); 741 + } 742 + 730 743 #[cfg(feature = "fullstack-server")] 731 744 _res?; 732 745
+4 -4
crates/weaver-app/src/data.rs
··· 421 421 ) -> (Resource<Option<String>>, Memo<Option<String>>) { 422 422 let fetcher = use_context::<crate::fetch::Fetcher>(); 423 423 let fetcher = fetcher.clone(); 424 - let res = use_resource(move || { 424 + let res = use_resource(use_reactive!(|(content, ident)| { 425 425 let fetcher = fetcher.clone(); 426 426 async move { 427 427 let entry = content(); ··· 434 434 435 435 Some(render_markdown_impl(entry, did, resolved_content).await) 436 436 } 437 - }); 438 - let memo = use_memo(move || { 437 + })); 438 + let memo = use_memo(use_reactive!(|res| { 439 439 if let Some(Some(value)) = &*res.read() { 440 440 Some(value.clone()) 441 441 } else { 442 442 None 443 443 } 444 - }); 444 + })); 445 445 (res, memo) 446 446 } 447 447
+22 -8
crates/weaver-index/src/endpoints/notebook.rs
··· 139 139 .maybe_path(non_empty_cowstr(&notebook_row.path)) 140 140 .build(); 141 141 142 - // Build entry views 143 - let mut entries: Vec<BookEntryView<'static>> = Vec::with_capacity(entry_rows.len()); 144 - for (idx, entry_row) in entry_rows.iter().enumerate() { 142 + // Build entry views (first pass: create EntryViews) 143 + let mut entry_views: Vec<EntryView<'static>> = Vec::with_capacity(entry_rows.len()); 144 + for entry_row in entry_rows.iter() { 145 145 let entry_uri = AtUri::new(&entry_row.uri).map_err(|e| { 146 146 tracing::error!("Invalid entry URI in db: {}", e); 147 147 XrpcErrorResponse::internal_error("Invalid URI stored") ··· 185 185 .maybe_path(non_empty_cowstr(&entry_row.path)) 186 186 .build(); 187 187 188 - let book_entry = BookEntryView::new() 189 - .entry(entry_view) 190 - .index(idx as i64) 191 - .build(); 188 + entry_views.push(entry_view); 189 + } 192 190 193 - entries.push(book_entry); 191 + // Build BookEntryViews with prev/next navigation 192 + let mut entries: Vec<BookEntryView<'static>> = Vec::with_capacity(entry_views.len()); 193 + for (idx, entry_view) in entry_views.iter().enumerate() { 194 + let prev = (idx > 0) 195 + .then(|| BookEntryRef::new().entry(entry_views[idx - 1].clone()).build()); 196 + let next = entry_views 197 + .get(idx + 1) 198 + .map(|e| BookEntryRef::new().entry(e.clone()).build()); 199 + 200 + entries.push( 201 + BookEntryView::new() 202 + .entry(entry_view.clone()) 203 + .index(idx as i64) 204 + .maybe_prev(prev) 205 + .maybe_next(next) 206 + .build(), 207 + ); 194 208 } 195 209 196 210 // Build cursor for pagination (position-based)
+9 -8
crates/weaver-renderer/src/css.rs
··· 322 322 color: var(--color-primary); 323 323 }} 324 324 325 - /* Aside blocks (via WeaverBlock prefix) */ 326 - aside, .aside {{ 325 + /* Aside blocks (via WeaverBlock prefix) - scoped to notebook content */ 326 + .notebook-content aside, 327 + .notebook-content .aside {{ 327 328 float: left; 328 329 width: 40%; 329 330 margin: 0 1.5rem 1rem 0; ··· 334 335 clear: left; 335 336 }} 336 337 337 - aside > *:first-child, 338 - .aside > *:first-child {{ 338 + .notebook-content aside > *:first-child, 339 + .notebook-content .aside > *:first-child {{ 339 340 margin-top: 0; 340 341 }} 341 342 342 - aside > *:last-child, 343 - .aside > *:last-child {{ 343 + .notebook-content aside > *:last-child, 344 + .notebook-content .aside > *:last-child {{ 344 345 margin-bottom: 0; 345 346 }} 346 347 347 348 /* Reset blockquote styling inside asides */ 348 - aside > blockquote, 349 - .aside > blockquote {{ 349 + .notebook-content aside > blockquote, 350 + .notebook-content .aside > blockquote {{ 350 351 border-left: none; 351 352 background: transparent; 352 353 padding: 0;
+1279
test-sidenotes.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>test-sidenotes</title> 7 + <style> 8 + /* CSS Reset */ 9 + *, *::before, *::after { 10 + box-sizing: border-box; 11 + margin: 0; 12 + padding: 0; 13 + } 14 + 15 + /* CSS Variables - Light Mode (default) */ 16 + :root { 17 + --color-base: #faf4ed; 18 + --color-surface: #fffaf3; 19 + --color-overlay: #f2e9e1; 20 + --color-text: #1f1d2e; 21 + --color-muted: #635e74; 22 + --color-subtle: #4a4560; 23 + --color-emphasis: #1e1a2d; 24 + --color-primary: #907aa9; 25 + --color-secondary: #56949f; 26 + --color-tertiary: #286983; 27 + --color-error: #b4637a; 28 + --color-warning: #ea9d34; 29 + --color-success: #286983; 30 + --color-border: #dfdad9; 31 + --color-link: #d7827e; 32 + --color-highlight: #cecacd; 33 + 34 + --font-body: 'Adobe Caslon Pro','Latin Modern Roman','Times New Roman','serif'; 35 + --font-heading: 'IBM Plex Sans','system-ui','sans-serif'; 36 + --font-mono: 'Ioskeley Mono','IBM Plex Mono','Berkeley Mono','Consolas','monospace'; 37 + 38 + --spacing-base: 16px; 39 + --spacing-line-height: 1.6; 40 + --spacing-scale: 1.25; 41 + } 42 + 43 + /* CSS Variables - Dark Mode */ 44 + @media (prefers-color-scheme: dark) { 45 + :root { 46 + --color-base: #191724; 47 + --color-surface: #1f1d2e; 48 + --color-overlay: #26233a; 49 + --color-text: #e0def4; 50 + --color-muted: #6e6a86; 51 + --color-subtle: #908caa; 52 + --color-emphasis: #e0def4; 53 + --color-primary: #c4a7e7; 54 + --color-secondary: #9ccfd8; 55 + --color-tertiary: #ebbcba; 56 + --color-error: #eb6f92; 57 + --color-warning: #f6c177; 58 + --color-success: #31748f; 59 + --color-border: #403d52; 60 + --color-link: #ebbcba; 61 + --color-highlight: #524f67; 62 + } 63 + } 64 + 65 + /* Base Styles */ 66 + html { 67 + font-size: var(--spacing-base); 68 + line-height: var(--spacing-line-height); 69 + } 70 + 71 + /* Scoped to notebook-content container */ 72 + .notebook-content { 73 + font-family: var(--font-body); 74 + color: var(--color-text); 75 + background-color: var(--color-base); 76 + margin: 0 auto; 77 + padding: 1rem 0rem; 78 + word-wrap: break-word; 79 + overflow-wrap: break-word; 80 + counter-reset: sidenote-counter; 81 + max-width: 95ch; 82 + } 83 + 84 + /* When sidenotes exist, body padding creates the gutter */ 85 + /* Left padding shrinks first as viewport narrows, right stays for sidenotes */ 86 + body:has(.sidenote) { 87 + padding-left: clamp(0rem, calc((100vw - 95ch - 15.5rem - 2rem) / 2), 15.5rem); 88 + padding-right: 15.5rem; 89 + } 90 + 91 + /* Typography */ 92 + h1, h2, h3, h4, h5, h6 { 93 + font-family: var(--font-heading); 94 + margin-top: calc(1rem * var(--spacing-scale)); 95 + margin-bottom: 0.5rem; 96 + line-height: 1.2; 97 + } 98 + 99 + h1 { 100 + font-size: 2rem; 101 + color: var(--color-secondary); 102 + } 103 + h2 { 104 + font-size: 1.5rem; 105 + color: var(--color-primary); 106 + } 107 + h3 { 108 + font-size: 1.25rem; 109 + color: var(--color-secondary); 110 + } 111 + h4 { 112 + font-size: 1.2rem; 113 + color: var(--color-tertiary); 114 + } 115 + h5 { 116 + font-size: 1.125rem; 117 + color: var(--color-secondary); 118 + } 119 + h6 { font-size: 1rem; } 120 + 121 + p { 122 + margin-bottom: 1rem; 123 + word-wrap: break-word; 124 + overflow-wrap: break-word; 125 + } 126 + 127 + a { 128 + color: var(--color-link); 129 + text-decoration: none; 130 + } 131 + 132 + .notebook-content a:hover { 133 + color: var(--color-emphasis); 134 + text-decoration: underline; 135 + } 136 + 137 + /* Wikilink validation (editor) */ 138 + .link-valid { 139 + color: var(--color-link); 140 + } 141 + 142 + .link-broken { 143 + color: var(--color-error); 144 + text-decoration: underline wavy; 145 + text-decoration-color: var(--color-error); 146 + opacity: 0.8; 147 + } 148 + 149 + /* Selection */ 150 + ::selection { 151 + background: var(--color-highlight); 152 + color: var(--color-text); 153 + } 154 + 155 + /* Lists */ 156 + ul, ol { 157 + margin-left: 1rem; 158 + margin-bottom: 1rem; 159 + } 160 + 161 + li { 162 + margin-bottom: 0.25rem; 163 + } 164 + 165 + /* Code */ 166 + code { 167 + font-family: var(--font-mono); 168 + background: var(--color-surface); 169 + padding: 0.125rem 0.25rem; 170 + border-radius: 4px; 171 + font-size: 0.9em; 172 + } 173 + 174 + pre { 175 + overflow-x: auto; 176 + margin-bottom: 1rem; 177 + border-radius: 5px; 178 + border: 1px solid var(--color-border); 179 + box-sizing: border-box; 180 + } 181 + 182 + /* Code blocks inside pre are handled by syntax theme */ 183 + pre code { 184 + 185 + display: block; 186 + width: fit-content; 187 + min-width: 100%; 188 + padding: 1rem; 189 + background: var(--color-surface); 190 + } 191 + 192 + /* Math */ 193 + .math { 194 + font-family: var(--font-mono); 195 + } 196 + 197 + .math-display { 198 + display: block; 199 + margin: 1rem 0; 200 + text-align: center; 201 + } 202 + 203 + /* Blockquotes */ 204 + blockquote { 205 + border-left: 2px solid var(--color-secondary); 206 + background: var(--color-surface); 207 + padding-left: 1rem; 208 + padding-right: 1rem; 209 + padding-top: 0.5rem; 210 + padding-bottom: 0.04rem; 211 + margin: 1rem 0; 212 + font-size: 0.95em; 213 + border-bottom-right-radius: 5px; 214 + border-top-right-radius: 5px; 215 + } 216 + } 217 + 218 + /* Tables */ 219 + table { 220 + border-collapse: collapse; 221 + width: 100%; 222 + margin-bottom: 1rem; 223 + display: block; 224 + overflow-x: auto; 225 + max-width: 100%; 226 + } 227 + 228 + th, td { 229 + border: 1px solid var(--color-border); 230 + padding: 0.5rem; 231 + text-align: left; 232 + } 233 + 234 + th { 235 + background: var(--color-surface); 236 + font-weight: 600; 237 + } 238 + 239 + tr:hover { 240 + background: var(--color-surface); 241 + } 242 + 243 + /* Footnotes */ 244 + .footnote-reference { 245 + font-size: 0.8em; 246 + color: var(--color-subtle); 247 + } 248 + 249 + .footnote-definition { 250 + order: 9999; 251 + margin: 0; 252 + padding: 0.5rem 0; 253 + font-size: 0.9em; 254 + } 255 + 256 + .footnote-definition:first-of-type { 257 + margin-top: 2rem; 258 + padding-top: 1rem; 259 + border-top: 2px solid var(--color-border); 260 + } 261 + 262 + .footnote-definition:first-of-type::before { 263 + content: "Footnotes"; 264 + display: block; 265 + font-weight: 600; 266 + font-size: 1.1em; 267 + color: var(--color-subtle); 268 + margin-bottom: 0.75rem; 269 + } 270 + 271 + .footnote-definition-label { 272 + font-weight: 600; 273 + margin-right: 0.5rem; 274 + color: var(--color-primary); 275 + } 276 + 277 + /* Aside blocks (via WeaverBlock prefix) - scoped to notebook content */ 278 + .notebook-content aside, 279 + .notebook-content .aside { 280 + float: left; 281 + width: 40%; 282 + margin: 0 1.5rem 1rem 0; 283 + padding: 1rem; 284 + background: var(--color-surface); 285 + border-right: 3px solid var(--color-primary); 286 + font-size: 0.9em; 287 + clear: left; 288 + } 289 + 290 + .notebook-content aside > *:first-child, 291 + .notebook-content .aside > *:first-child { 292 + margin-top: 0; 293 + } 294 + 295 + .notebook-content aside > *:last-child, 296 + .notebook-content .aside > *:last-child { 297 + margin-bottom: 0; 298 + } 299 + 300 + /* Reset blockquote styling inside asides */ 301 + .notebook-content aside > blockquote, 302 + .notebook-content .aside > blockquote { 303 + border-left: none; 304 + background: transparent; 305 + padding: 0; 306 + margin: 0; 307 + font-size: inherit; 308 + } 309 + 310 + /* Indent utilities */ 311 + .indent-1 { margin-left: 1em; } 312 + .indent-2 { margin-left: 2em; } 313 + .indent-3 { margin-left: 3em; } 314 + 315 + /* Tufte-style Sidenotes */ 316 + /* Hide checkbox for sidenote toggle */ 317 + .margin-toggle { 318 + display: none; 319 + } 320 + 321 + /* Sidenote number marker (inline superscript) */ 322 + .sidenote-number { 323 + counter-increment: sidenote-counter; 324 + } 325 + 326 + .sidenote-number::after { 327 + content: counter(sidenote-counter); 328 + font-size: 0.7em; 329 + position: relative; 330 + top: -0.5em; 331 + color: var(--color-primary); 332 + padding-left: 0.1em; 333 + } 334 + 335 + /* Sidenote content (margin notes on wide screens) */ 336 + .sidenote { 337 + float: right; 338 + clear: right; 339 + margin-right: -15.5rem; 340 + width: 14rem; 341 + margin-top: 0.3rem; 342 + margin-bottom: 1rem; 343 + font-size: 0.85em; 344 + line-height: 1.4; 345 + color: var(--color-subtle); 346 + } 347 + 348 + .sidenote::before { 349 + content: counter(sidenote-counter) ". "; 350 + color: var(--color-primary); 351 + } 352 + 353 + /* Mobile sidenotes: toggle behavior */ 354 + @media (max-width: 900px) { 355 + /* Reset sidenote gutter on mobile */ 356 + body:has(.sidenote) { 357 + padding-right: 0; 358 + } 359 + 360 + aside, .aside { 361 + float: none; 362 + width: 100%; 363 + margin: 1rem 0; 364 + } 365 + 366 + .sidenote { 367 + display: none; 368 + } 369 + 370 + .margin-toggle:checked + .sidenote { 371 + display: block; 372 + float: none; 373 + width: 95%; 374 + margin: 0.5rem 2.5%; 375 + padding: 0.5rem; 376 + background: var(--color-surface); 377 + border-left: 2px solid var(--color-primary); 378 + } 379 + 380 + label.sidenote-number { 381 + cursor: pointer; 382 + } 383 + 384 + label.sidenote-number::after { 385 + text-decoration: underline; 386 + } 387 + } 388 + 389 + /* Images */ 390 + img { 391 + max-width: 100%; 392 + height: auto; 393 + display: block; 394 + margin: 1rem 0; 395 + border-radius: 4px; 396 + } 397 + 398 + /* Hygiene for iframes */ 399 + .html-embed-block { 400 + max-width: 100%; 401 + height: auto; 402 + display: block; 403 + margin: 1rem 0; 404 + } 405 + 406 + /* AT Protocol Embeds - Container */ 407 + /* Light mode: paper with shadow, dark mode: blueprint with borders */ 408 + .atproto-embed { 409 + display: block; 410 + position: relative; 411 + max-width: 550px; 412 + margin: 1rem 0; 413 + padding: 1rem; 414 + background: var(--color-surface); 415 + border-left: 2px solid var(--color-secondary); 416 + box-shadow: 0 1px 2px color-mix(in srgb, var(--color-text) 8%, transparent); 417 + } 418 + 419 + .atproto-embed:hover { 420 + border-left-color: var(--color-primary); 421 + } 422 + 423 + @media (prefers-color-scheme: dark) { 424 + .atproto-embed { 425 + box-shadow: none; 426 + border: 1px solid var(--color-border); 427 + border-left: 2px solid var(--color-secondary); 428 + } 429 + } 430 + 431 + .atproto-embed-placeholder { 432 + color: var(--color-muted); 433 + font-style: italic; 434 + } 435 + 436 + .embed-loading { 437 + display: block; 438 + padding: 0.5rem 0; 439 + color: var(--color-subtle); 440 + font-family: var(--font-mono); 441 + font-size: 0.85rem; 442 + } 443 + 444 + /* Embed Author Block */ 445 + .embed-author { 446 + display: flex; 447 + align-items: center; 448 + gap: 0.75rem; 449 + padding-bottom: 0.5rem; 450 + } 451 + 452 + .embed-avatar { 453 + width: 36px; 454 + height: 36px; 455 + max-width: 36px; 456 + max-height: 36px; 457 + aspect-ratio: 1; 458 + margin: 0; 459 + object-fit: cover; 460 + } 461 + 462 + .embed-author-info { 463 + display: flex; 464 + flex-direction: column; 465 + gap: 0; 466 + min-width: 0; 467 + } 468 + 469 + .embed-avatar-link { 470 + display: block; 471 + flex-shrink: 0; 472 + } 473 + 474 + .embed-author-name { 475 + font-weight: 600; 476 + color: var(--color-text); 477 + overflow: hidden; 478 + text-overflow: ellipsis; 479 + white-space: nowrap; 480 + text-decoration: none; 481 + line-height: 1.2; 482 + } 483 + 484 + a.embed-author-name:hover { 485 + color: var(--color-link); 486 + } 487 + 488 + .embed-author-handle { 489 + font-size: 0.85em; 490 + font-family: var(--font-mono); 491 + color: var(--color-subtle); 492 + text-decoration: none; 493 + overflow: hidden; 494 + text-overflow: ellipsis; 495 + white-space: nowrap; 496 + line-height: 1.2; 497 + } 498 + 499 + .embed-author-handle:hover { 500 + color: var(--color-link); 501 + } 502 + 503 + /* Card-wide clickable link (sits behind content) */ 504 + .embed-card-link { 505 + position: absolute; 506 + inset: 0; 507 + z-index: 0; 508 + } 509 + 510 + .embed-card-link:focus { 511 + outline: 2px solid var(--color-primary); 512 + outline-offset: 2px; 513 + } 514 + 515 + /* Interactive elements sit above the card link */ 516 + .embed-author, 517 + .embed-external, 518 + .embed-quote, 519 + .embed-images, 520 + .embed-meta { 521 + position: relative; 522 + z-index: 1; 523 + } 524 + 525 + /* Embed Content Block */ 526 + .embed-content { 527 + display: block; 528 + color: var(--color-text); 529 + line-height: 1.5; 530 + margin-bottom: 0.75rem; 531 + white-space: pre-wrap; 532 + } 533 + 534 + 535 + 536 + .embed-description { 537 + display: block; 538 + color: var(--color-text); 539 + font-size: 0.95em; 540 + line-height: 1.4; 541 + } 542 + 543 + /* Embed Metadata Block */ 544 + .embed-meta { 545 + display: flex; 546 + justify-content: space-between; 547 + align-items: center; 548 + font-size: 0.85em; 549 + color: var(--color-muted); 550 + margin-top: 0.75rem; 551 + } 552 + 553 + .embed-stats { 554 + display: flex; 555 + gap: 1rem; 556 + font-family: var(--font-mono); 557 + } 558 + 559 + .embed-stat { 560 + color: var(--color-subtle); 561 + font-size: 0.9em; 562 + } 563 + 564 + .embed-time { 565 + color: var(--color-subtle); 566 + text-decoration: none; 567 + font-family: var(--font-mono); 568 + font-size: 0.9em; 569 + } 570 + 571 + .embed-time:hover { 572 + color: var(--color-link); 573 + } 574 + 575 + .embed-type { 576 + font-size: 0.8em; 577 + color: var(--color-subtle); 578 + font-family: var(--font-mono); 579 + text-transform: uppercase; 580 + letter-spacing: 0.05em; 581 + } 582 + 583 + /* Embed URL link (shown with syntax in editor) */ 584 + .embed-url { 585 + color: var(--color-link); 586 + font-family: var(--font-mono); 587 + font-size: 0.9em; 588 + word-break: break-all; 589 + } 590 + 591 + /* External link cards */ 592 + .embed-external { 593 + display: flex; 594 + gap: 0.75rem; 595 + padding: 0.75rem; 596 + background: var(--color-surface); 597 + border: 1px dashed var(--color-border); 598 + text-decoration: none; 599 + color: inherit; 600 + margin-top: 0.5rem; 601 + } 602 + 603 + .embed-external:hover { 604 + border-left: 2px solid var(--color-primary); 605 + margin-left: -1px; 606 + } 607 + 608 + @media (prefers-color-scheme: dark) { 609 + .embed-external { 610 + border: 1px solid var(--color-border); 611 + } 612 + 613 + .embed-external:hover { 614 + border-left: 2px solid var(--color-primary); 615 + margin-left: -1px; 616 + } 617 + } 618 + 619 + .embed-external-thumb { 620 + width: 120px; 621 + height: 80px; 622 + object-fit: cover; 623 + flex-shrink: 0; 624 + } 625 + 626 + .embed-external-info { 627 + display: flex; 628 + flex-direction: column; 629 + gap: 0.25rem; 630 + min-width: 0; 631 + } 632 + 633 + .embed-external-title { 634 + font-weight: 600; 635 + color: var(--color-text); 636 + overflow: hidden; 637 + text-overflow: ellipsis; 638 + white-space: nowrap; 639 + } 640 + 641 + .embed-external-description { 642 + font-size: 0.9em; 643 + color: var(--color-muted); 644 + overflow: hidden; 645 + text-overflow: ellipsis; 646 + display: -webkit-box; 647 + -webkit-line-clamp: 2; 648 + -webkit-box-orient: vertical; 649 + } 650 + 651 + .embed-external-url { 652 + font-size: 0.8em; 653 + font-family: var(--font-mono); 654 + color: var(--color-subtle); 655 + } 656 + 657 + /* Image embeds */ 658 + .embed-images { 659 + display: grid; 660 + gap: 4px; 661 + margin-top: 0.5rem; 662 + overflow: hidden; 663 + } 664 + 665 + .embed-images-1 { 666 + grid-template-columns: 1fr; 667 + } 668 + 669 + .embed-images-2 { 670 + grid-template-columns: 1fr 1fr; 671 + } 672 + 673 + .embed-images-3 { 674 + grid-template-columns: 1fr 1fr; 675 + } 676 + 677 + .embed-images-4 { 678 + grid-template-columns: 1fr 1fr; 679 + } 680 + 681 + .embed-image-link { 682 + display: block; 683 + line-height: 0; 684 + } 685 + 686 + .embed-image { 687 + width: 100%; 688 + height: auto; 689 + max-height: 500px; 690 + object-fit: cover; 691 + object-position: center; 692 + margin: 0; 693 + } 694 + 695 + /* Quoted records */ 696 + .embed-quote { 697 + display: block; 698 + margin-top: 0.5rem; 699 + padding: 0.75rem; 700 + background: var(--color-overlay); 701 + border-left: 2px solid var(--color-tertiary); 702 + } 703 + 704 + @media (prefers-color-scheme: dark) { 705 + .embed-quote { 706 + border: 1px solid var(--color-border); 707 + border-left: 2px solid var(--color-tertiary); 708 + } 709 + } 710 + 711 + .embed-quote .embed-author { 712 + margin-bottom: 0.5rem; 713 + } 714 + 715 + .embed-quote .embed-avatar { 716 + width: 24px; 717 + height: 24px; 718 + min-width: 24px; 719 + min-height: 24px; 720 + max-width: 24px; 721 + max-height: 24px; 722 + } 723 + 724 + .embed-quote .embed-content { 725 + font-size: 0.95em; 726 + margin-bottom: 0; 727 + } 728 + 729 + /* Placeholder states */ 730 + .embed-video-placeholder, 731 + .embed-not-found, 732 + .embed-blocked, 733 + .embed-detached, 734 + .embed-unknown { 735 + display: block; 736 + padding: 1rem; 737 + background: var(--color-overlay); 738 + border-left: 2px solid var(--color-border); 739 + color: var(--color-muted); 740 + font-style: italic; 741 + margin-top: 0.5rem; 742 + font-family: var(--font-mono); 743 + font-size: 0.9em; 744 + } 745 + 746 + @media (prefers-color-scheme: dark) { 747 + .embed-video-placeholder, 748 + .embed-not-found, 749 + .embed-blocked, 750 + .embed-detached, 751 + .embed-unknown { 752 + border: 1px dashed var(--color-border); 753 + } 754 + } 755 + 756 + /* Record card embeds (feeds, lists, labelers, starter packs) */ 757 + .embed-record-card { 758 + display: block; 759 + margin-top: 0.5rem; 760 + padding: 0.75rem; 761 + background: var(--color-overlay); 762 + border-left: 2px solid var(--color-tertiary); 763 + } 764 + 765 + .embed-record-card > .embed-author-name { 766 + display: block; 767 + font-size: 1.1em; 768 + } 769 + 770 + .embed-subtitle { 771 + display: block; 772 + font-size: 0.85em; 773 + color: var(--color-muted); 774 + margin-bottom: 0.5rem; 775 + } 776 + 777 + .embed-record-card .embed-description { 778 + display: block; 779 + margin: 0.5rem 0; 780 + } 781 + 782 + .embed-record-card .embed-stats { 783 + display: block; 784 + margin-top: 0.25rem; 785 + } 786 + 787 + /* Generic record fields */ 788 + .embed-fields { 789 + display: block; 790 + margin-top: 0.5rem; 791 + font-family: var(--font-ui); 792 + font-size: 0.85rem; 793 + color: var(--color-muted); 794 + } 795 + 796 + .embed-field { 797 + display: block; 798 + margin-top: 0.25rem; 799 + } 800 + 801 + /* Nested fields get indentation */ 802 + .embed-fields .embed-fields { 803 + display: block; 804 + margin-top: 0.5rem; 805 + margin-left: 1rem; 806 + padding-left: 0.5rem; 807 + border-left: 1px solid var(--color-border); 808 + } 809 + 810 + /* Type label inside fields should be block with spacing */ 811 + .embed-fields > .embed-author-handle { 812 + display: block; 813 + margin-bottom: 0.25rem; 814 + } 815 + 816 + .embed-field-name { 817 + color: var(--color-subtle); 818 + } 819 + 820 + .embed-field-number { 821 + color: var(--color-tertiary); 822 + } 823 + 824 + .embed-field-date { 825 + color: var(--color-muted); 826 + } 827 + 828 + .embed-field-count { 829 + color: var(--color-muted); 830 + font-style: italic; 831 + } 832 + 833 + .embed-field-bool-true { 834 + color: var(--color-success); 835 + } 836 + 837 + .embed-field-bool-false { 838 + color: var(--color-muted); 839 + } 840 + 841 + .embed-field-link, 842 + .embed-field-aturi { 843 + color: var(--color-link); 844 + text-decoration: none; 845 + } 846 + 847 + .embed-field-link:hover, 848 + .embed-field-aturi:hover { 849 + text-decoration: underline; 850 + } 851 + 852 + .embed-field-did { 853 + font-family: var(--font-mono); 854 + font-size: 0.9em; 855 + } 856 + 857 + .embed-field-did .did-scheme, 858 + .embed-field-did .did-separator { 859 + color: var(--color-muted); 860 + } 861 + 862 + .embed-field-did .did-method { 863 + color: var(--color-tertiary); 864 + } 865 + 866 + .embed-field-did .did-identifier { 867 + color: var(--color-text); 868 + } 869 + 870 + .embed-field-nsid { 871 + color: var(--color-secondary); 872 + } 873 + 874 + .embed-field-handle { 875 + color: var(--color-link); 876 + } 877 + 878 + /* AT URI highlighting */ 879 + .aturi-scheme { 880 + color: var(--color-muted); 881 + } 882 + 883 + .aturi-slash { 884 + color: var(--color-muted); 885 + } 886 + 887 + .aturi-authority { 888 + color: var(--color-link); 889 + } 890 + 891 + .aturi-collection { 892 + color: var(--color-secondary); 893 + } 894 + 895 + .aturi-rkey { 896 + color: var(--color-tertiary); 897 + } 898 + 899 + /* Generic AT Protocol record embed */ 900 + .atproto-record > .embed-author-handle { 901 + display: block; 902 + margin-bottom: 0.25rem; 903 + } 904 + 905 + .atproto-record > .embed-author-name { 906 + display: block; 907 + margin-bottom: 0.5rem; 908 + } 909 + 910 + .atproto-record > .embed-content { 911 + margin-bottom: 0.5rem; 912 + } 913 + 914 + /* Notebook entry embed - full width, expandable */ 915 + .atproto-entry { 916 + max-width: none; 917 + width: 100%; 918 + margin: 1.5rem 0; 919 + padding: 0; 920 + background: var(--color-surface); 921 + border: 1px solid var(--color-border); 922 + border-left: 1px solid var(--color-border); 923 + box-shadow: none; 924 + overflow: hidden; 925 + } 926 + 927 + .atproto-entry:hover { 928 + border-left-color: var(--color-border); 929 + } 930 + 931 + @media (prefers-color-scheme: dark) { 932 + .atproto-entry { 933 + border: 1px solid var(--color-border); 934 + border-left: 1px solid var(--color-border); 935 + } 936 + } 937 + 938 + .embed-entry-header { 939 + display: flex; 940 + flex-wrap: wrap; 941 + align-items: baseline; 942 + gap: 0.5rem 1rem; 943 + padding: 0.75rem 1rem; 944 + background: var(--color-overlay); 945 + border-bottom: 1px solid var(--color-border); 946 + } 947 + 948 + .embed-entry-title { 949 + font-size: 1.1em; 950 + font-weight: 600; 951 + color: var(--color-text); 952 + } 953 + 954 + .embed-entry-author { 955 + font-size: 0.85em; 956 + color: var(--color-muted); 957 + } 958 + 959 + /* Hidden checkbox for expand/collapse */ 960 + .embed-entry-toggle { 961 + display: none; 962 + } 963 + 964 + /* Content wrapper - scrollable when collapsed */ 965 + .embed-entry-content { 966 + max-height: 30rem; 967 + overflow-y: auto; 968 + padding: 1rem; 969 + transition: max-height 0.3s ease; 970 + } 971 + 972 + /* When checkbox is checked, expand fully */ 973 + .embed-entry-toggle:checked ~ .embed-entry-content { 974 + max-height: none; 975 + } 976 + 977 + /* Expand/collapse button */ 978 + .embed-entry-expand { 979 + display: block; 980 + width: 100%; 981 + padding: 0.5rem; 982 + text-align: center; 983 + font-size: 0.85em; 984 + font-family: var(--font-ui); 985 + color: var(--color-muted); 986 + background: var(--color-overlay); 987 + border-top: 1px solid var(--color-border); 988 + cursor: pointer; 989 + user-select: none; 990 + } 991 + 992 + .embed-entry-expand:hover { 993 + color: var(--color-text); 994 + background: var(--color-surface); 995 + } 996 + 997 + /* Toggle button text */ 998 + .embed-entry-expand::before { 999 + content: "Expand ↓"; 1000 + } 1001 + 1002 + .embed-entry-toggle:checked ~ .embed-entry-expand::before { 1003 + content: "Collapse ↑"; 1004 + } 1005 + 1006 + /* Hide expand button if content doesn't overflow (via JS class) */ 1007 + .atproto-entry.no-overflow .embed-entry-expand { 1008 + display: none; 1009 + } 1010 + 1011 + /* Horizontal Rule */ 1012 + hr { 1013 + border: none; 1014 + border-top: 2px solid var(--color-border); 1015 + margin: 2rem 0; 1016 + } 1017 + 1018 + /* Tablet and mobile responsiveness */ 1019 + @media (max-width: 900px) { 1020 + .notebook-content { 1021 + padding: 1.5rem 1rem; 1022 + max-width: 100%; 1023 + } 1024 + 1025 + h1 { font-size: 1.85rem; } 1026 + h2 { font-size: 1.4rem; } 1027 + h3 { font-size: 1.2rem; } 1028 + 1029 + blockquote { 1030 + margin-left: 0; 1031 + margin-right: 0; 1032 + } 1033 + } 1034 + 1035 + /* Small mobile phones */ 1036 + @media (max-width: 480px) { 1037 + .notebook-content { 1038 + padding: 1rem 0.75rem; 1039 + } 1040 + 1041 + h1 { font-size: 1.65rem; } 1042 + h2 { font-size: 1.3rem; } 1043 + h3 { font-size: 1.1rem; } 1044 + 1045 + blockquote { 1046 + padding-left: 0.75rem; 1047 + padding-right: 0.75rem; 1048 + } 1049 + } 1050 + </style> 1051 + <style> 1052 + /* Syntax highlighting - Light Mode (default) */ 1053 + /* 1054 + * theme "Rosé Pine Dawn" generated by syntect 1055 + */ 1056 + 1057 + .wvc-code { 1058 + color: #575279; 1059 + background-color: #faf4ed; 1060 + } 1061 + 1062 + .wvc-comment { 1063 + color: #797593; 1064 + font-style: italic; 1065 + } 1066 + .wvc-string, .wvc-punctuation.wvc-definition.wvc-string { 1067 + color: #ea9d34; 1068 + } 1069 + .wvc-constant.wvc-numeric { 1070 + color: #ea9d34; 1071 + } 1072 + .wvc-constant.wvc-language { 1073 + color: #ea9d34; 1074 + font-weight: bold; 1075 + } 1076 + .wvc-constant.wvc-character, .wvc-constant.wvc-other { 1077 + color: #ea9d34; 1078 + } 1079 + .wvc-variable { 1080 + color: #575279; 1081 + font-style: italic; 1082 + } 1083 + .wvc-keyword { 1084 + color: #286983; 1085 + } 1086 + .wvc-storage { 1087 + color: #56949f; 1088 + } 1089 + .wvc-storage.wvc-type { 1090 + color: #56949f; 1091 + } 1092 + .wvc-entity.wvc-name.wvc-class { 1093 + color: #286983; 1094 + font-weight: bold; 1095 + } 1096 + .wvc-entity.wvc-other.wvc-inherited-class { 1097 + color: #286983; 1098 + font-style: italic; 1099 + } 1100 + .wvc-entity.wvc-name.wvc-function { 1101 + color: #d7827e; 1102 + font-style: italic; 1103 + } 1104 + .wvc-variable.wvc-parameter { 1105 + color: #907aa9; 1106 + } 1107 + .wvc-entity.wvc-name.wvc-tag { 1108 + color: #286983; 1109 + font-weight: bold; 1110 + } 1111 + .wvc-entity.wvc-other.wvc-attribute-name { 1112 + color: #907aa9; 1113 + } 1114 + .wvc-support.wvc-function { 1115 + color: #d7827e; 1116 + font-weight: bold; 1117 + } 1118 + .wvc-support.wvc-constant { 1119 + color: #ea9d34; 1120 + font-weight: bold; 1121 + } 1122 + .wvc-support.wvc-type, .wvc-support.wvc-class { 1123 + color: #56949f; 1124 + font-weight: bold; 1125 + } 1126 + .wvc-support.wvc-other.wvc-variable { 1127 + color: #b4637a; 1128 + font-weight: bold; 1129 + } 1130 + .wvc-invalid { 1131 + color: #575279; 1132 + background-color: #b4637a; 1133 + } 1134 + .wvc-invalid.wvc-deprecated { 1135 + color: #575279; 1136 + background-color: #907aa9; 1137 + } 1138 + .wvc-punctuation, .wvc-keyword.wvc-operator { 1139 + color: #797593; 1140 + } 1141 + 1142 + 1143 + /* Syntax highlighting - Dark Mode */ 1144 + @media (prefers-color-scheme: dark) { 1145 + /* 1146 + * theme "Rosé Pine" generated by syntect 1147 + */ 1148 + 1149 + .wvc-code { 1150 + color: #e0def4; 1151 + background-color: #191724; 1152 + } 1153 + 1154 + .wvc-comment { 1155 + color: #908caa; 1156 + font-style: italic; 1157 + } 1158 + .wvc-string, .wvc-punctuation.wvc-definition.wvc-string { 1159 + color: #f6c177; 1160 + } 1161 + .wvc-constant.wvc-numeric { 1162 + color: #f6c177; 1163 + } 1164 + .wvc-constant.wvc-language { 1165 + color: #f6c177; 1166 + font-weight: bold; 1167 + } 1168 + .wvc-constant.wvc-character, .wvc-constant.wvc-other { 1169 + color: #f6c177; 1170 + } 1171 + .wvc-variable { 1172 + color: #e0def4; 1173 + font-style: italic; 1174 + } 1175 + .wvc-keyword { 1176 + color: #31748f; 1177 + } 1178 + .wvc-storage { 1179 + color: #9ccfd8; 1180 + } 1181 + .wvc-storage.wvc-type { 1182 + color: #9ccfd8; 1183 + } 1184 + .wvc-entity.wvc-name.wvc-class { 1185 + color: #31748f; 1186 + font-weight: bold; 1187 + } 1188 + .wvc-entity.wvc-other.wvc-inherited-class { 1189 + color: #31748f; 1190 + font-style: italic; 1191 + } 1192 + .wvc-entity.wvc-name.wvc-function { 1193 + color: #ebbcba; 1194 + font-style: italic; 1195 + } 1196 + .wvc-variable.wvc-parameter { 1197 + color: #c4a7e7; 1198 + } 1199 + .wvc-entity.wvc-name.wvc-tag { 1200 + color: #31748f; 1201 + font-weight: bold; 1202 + } 1203 + .wvc-entity.wvc-other.wvc-attribute-name { 1204 + color: #c4a7e7; 1205 + } 1206 + .wvc-support.wvc-function { 1207 + color: #ebbcba; 1208 + font-weight: bold; 1209 + } 1210 + .wvc-support.wvc-constant { 1211 + color: #f6c177; 1212 + font-weight: bold; 1213 + } 1214 + .wvc-support.wvc-type, .wvc-support.wvc-class { 1215 + color: #9ccfd8; 1216 + font-weight: bold; 1217 + } 1218 + .wvc-support.wvc-other.wvc-variable { 1219 + color: #eb6f92; 1220 + font-weight: bold; 1221 + } 1222 + .wvc-invalid { 1223 + color: #e0def4; 1224 + background-color: #eb6f92; 1225 + } 1226 + .wvc-invalid.wvc-deprecated { 1227 + color: #e0def4; 1228 + background-color: #c4a7e7; 1229 + } 1230 + .wvc-punctuation, .wvc-keyword.wvc-operator { 1231 + color: #908caa; 1232 + } 1233 + } 1234 + </style> 1235 + </head> 1236 + <body style="background: var(--color-base); min-height: 100vh;"> 1237 + <div class="notebook-content"> 1238 + <h1>Weaver: Long-form Writing on AT Protocol</h1> 1239 + <p><em>Or: "Get in kid, we're rebuilding the blogosphere!"</em></p> 1240 + <p>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.<label for="sn-1" class="sidenote-number"></label><input type="checkbox" id="sn-1" class="margin-toggle"/><span class="sidenote">Hi Rahaeli. Sorry I was the wrong kind of nerd.</span></p> 1241 + <blockquote> 1242 + <p><img src="weaver_photo_med.jpg" alt="weaver_photo_med.jpg" /><em>The namesake of what I'm building</em></p> 1243 + </blockquote> 1244 + <p>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.</p> 1245 + <h2>The Blogosphere</h2> 1246 + <p>I am an atheist in large part because of a blog called Common Sense Atheism.<label for="sn-2" class="sidenote-number"></label><input type="checkbox" id="sn-2" class="margin-toggle"/><span class="sidenote">The author, Luke Muehlhauser, was criticising both Richard Dawkins <em>and</em> some Christian apologetics I was familiar with.</span> 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,<label for="sn-3" class="sidenote-number"></label><input type="checkbox" id="sn-3" class="margin-toggle"/><span class="sidenote">Specifically their piece on the <a href="https://thingofthings.wordpress.com/2017/05/05/the-cluster-structure-of-genderspace/">cluster structure of genderspace</a>.</span> 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.<label for="sn-4" class="sidenote-number"></label><input type="checkbox" id="sn-4" class="margin-toggle"/><span class="sidenote">Amusingly I now think that being on Twitter and now Bluesky made me a better writer. Restrictions breed creativity, after all.</span></p> 1247 + <aside> 1248 + <blockquote> 1249 + <p><strong>On Platform Decay</strong></p> 1250 + <p>Through all of this I was never really satisfied with the options that were out there for long-form writing. Wordpress required too much setup. Tumblr's system for comments remains insane. Hosting my own seemed like too much money to burn on something nobody might read.</p> 1251 + </blockquote> 1252 + </aside> 1253 + <p>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.</p> 1254 + <p>Plus, I'm loathe to enable a centralised platform like Substack where the owners are unfortunately friendly to fascists.<label for="sn-5" class="sidenote-number"></label><input type="checkbox" id="sn-5" class="margin-toggle"/><span class="sidenote">I am very much a fan of freedom of expression. I'm not so much a fan of paying money to Nazis.</span>That's where the <code>at://</code> protocol and Weaver comes in.</p> 1255 + <h2>The Pitch</h2> 1256 + <p>Weaver is designed to be a highly flexible platform for medium and long-form writing on atproto.<label for="sn-6" class="sidenote-number"></label><input type="checkbox" id="sn-6" class="margin-toggle"/><span class="sidenote">The weaver bird builds intricate, self-contained homes—seemed fitting for a platform about owning your writing.</span> 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.</p> 1257 + <aside> 1258 + <blockquote> 1259 + <p><strong>The Ultimate Goal</strong></p> 1260 + <p>Build a platform suitable for professional writers and journalists, an open alternative to platforms like Substack, with ways for readers to support writers, all on the <code>at://</code> protocol.</p> 1261 + </blockquote> 1262 + </aside> 1263 + <h2>How It Works</h2> 1264 + <p>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.</p> 1265 + <p>You own what you write.<label for="sn-7" class="sidenote-number"></label><input type="checkbox" id="sn-7" class="margin-toggle"/><span class="sidenote">Technically you can include entries you don't control in your notebooks, although this isn't a supported mode—it's about <em>your</em> ownership of <em>your</em> words.</span> 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.<label for="sn-8" class="sidenote-number"></label><input type="checkbox" id="sn-8" class="margin-toggle"/><span class="sidenote">I forked the popular rust markdown processing library <code>pulldown-cmark</code> 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!</span> They support additional embed types, including atproto record embeds and other markdown documents, as well as resizable images.</p> 1266 + <h2>Why Rust?</h2> 1267 + <p>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.</p> 1268 + <aside> 1269 + <blockquote> 1270 + <p><strong>On Interoperability</strong></p> 1271 + <p>The <code>at://</code> protocol, while it was developed in concert with a microblogging app, is actually pretty damn good for "macroblogging" too. Weaver's app server can display Whitewind posts. With effort, it can faithfully render Leaflet posts. It doesn't care what app your profile is on.</p> 1272 + </blockquote> 1273 + </aside> 1274 + <h2>Evolution</h2> 1275 + <p>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.</p> 1276 + <p>If I screw this up, not too hard for someone else to pick up the torch and continue.<label for="sn-9" class="sidenote-number"></label><input type="checkbox" id="sn-9" class="margin-toggle"/><span class="sidenote">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.</span></p> 1277 + </div> 1278 + </body> 1279 + </html>