broke up mega-writer file in editor

Orual fb1645bc d22836ce

+3573 -2226
+33 -37
Cargo.lock
··· 980 981 [[package]] 982 name = "cc" 983 - version = "1.2.49" 984 source = "registry+https://github.com/rust-lang/crates.io-index" 985 - checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" 986 dependencies = [ 987 "find-msvc-tools", 988 "jobserver", ··· 2805 [[package]] 2806 name = "dioxus-primitives" 2807 version = "0.0.1" 2808 - source = "git+https://github.com/DioxusLabs/components#3564270718866d2e886f879973afc77d7c3a1689" 2809 dependencies = [ 2810 "dioxus 0.7.2", 2811 "dioxus-sdk-time", ··· 5568 5569 [[package]] 5570 name = "jacquard" 5571 - version = "0.9.4" 5572 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5573 dependencies = [ 5574 "bytes", 5575 "getrandom 0.2.16", ··· 5585 "regex", 5586 "regex-lite", 5587 "reqwest", 5588 - "ring", 5589 "serde", 5590 "serde_html_form", 5591 "serde_json", ··· 5600 5601 [[package]] 5602 name = "jacquard-api" 5603 - version = "0.9.2" 5604 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5605 dependencies = [ 5606 "bon", 5607 "bytes", ··· 5619 5620 [[package]] 5621 name = "jacquard-axum" 5622 - version = "0.9.2" 5623 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5624 dependencies = [ 5625 "axum", 5626 "bytes", ··· 5641 5642 [[package]] 5643 name = "jacquard-common" 5644 - version = "0.9.2" 5645 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5646 dependencies = [ 5647 "base64 0.22.1", 5648 "bon", ··· 5669 "regex", 5670 "regex-lite", 5671 "reqwest", 5672 - "ring", 5673 "serde", 5674 "serde_bytes", 5675 "serde_html_form", ··· 5689 5690 [[package]] 5691 name = "jacquard-derive" 5692 - version = "0.9.4" 5693 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5694 dependencies = [ 5695 "heck 0.5.0", 5696 "jacquard-lexicon", ··· 5701 5702 [[package]] 5703 name = "jacquard-identity" 5704 - version = "0.9.2" 5705 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5706 dependencies = [ 5707 "bon", 5708 "bytes", ··· 5712 "jacquard-common", 5713 "jacquard-lexicon", 5714 "miette 7.6.0", 5715 - "mini-moka 0.10.99", 5716 "n0-future 0.1.3", 5717 "percent-encoding", 5718 "reqwest", 5719 - "ring", 5720 "serde", 5721 "serde_html_form", 5722 "serde_json", ··· 5730 5731 [[package]] 5732 name = "jacquard-lexicon" 5733 - version = "0.9.2" 5734 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5735 dependencies = [ 5736 "cid", 5737 "dashmap 6.1.0", ··· 5756 5757 [[package]] 5758 name = "jacquard-oauth" 5759 - version = "0.9.2" 5760 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5761 dependencies = [ 5762 "base64 0.22.1", 5763 "bytes", ··· 5772 "miette 7.6.0", 5773 "p256", 5774 "rand 0.8.5", 5775 - "ring", 5776 "rouille", 5777 "serde", 5778 "serde_html_form", ··· 5789 5790 [[package]] 5791 name = "jacquard-repo" 5792 - version = "0.9.4" 5793 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 5794 dependencies = [ 5795 "bytes", 5796 "cid", ··· 6482 [[package]] 6483 name = "markdown-weaver" 6484 version = "0.13.0" 6485 - source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" 6486 dependencies = [ 6487 "bitflags 2.10.0", 6488 "getopts", ··· 6495 [[package]] 6496 name = "markdown-weaver-escape" 6497 version = "0.11.0" 6498 - source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#d1d3e3188bc0c52a060eb194a311f66c08e54377" 6499 6500 [[package]] 6501 name = "markup5ever" ··· 6747 6748 [[package]] 6749 name = "mini-moka" 6750 - version = "0.10.99" 6751 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#87e15baeadf555a107a56c25c7f2e0008f46a5e2" 6752 dependencies = [ 6753 "crossbeam-channel", 6754 "crossbeam-utils", ··· 6760 ] 6761 6762 [[package]] 6763 - name = "mini-moka" 6764 - version = "0.11.0" 6765 - source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d" 6766 dependencies = [ 6767 "crossbeam-channel", 6768 "crossbeam-utils", ··· 8030 8031 [[package]] 8032 name = "portable-atomic" 8033 - version = "1.11.1" 8034 source = "registry+https://github.com/rust-lang/crates.io-index" 8035 - checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" 8036 8037 [[package]] 8038 name = "portmapper" ··· 11679 "markdown-weaver", 11680 "markdown-weaver-escape", 11681 "mime-sniffer", 11682 - "mini-moka 0.11.0", 11683 "n0-future 0.1.3", 11684 "postcard", 11685 "regex", ··· 11801 "k256", 11802 "loro", 11803 "miette 7.6.0", 11804 - "mini-moka 0.11.0", 11805 "n0-future 0.1.3", 11806 "rand 0.8.5", 11807 "regex",
··· 980 981 [[package]] 982 name = "cc" 983 + version = "1.2.50" 984 source = "registry+https://github.com/rust-lang/crates.io-index" 985 + checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" 986 dependencies = [ 987 "find-msvc-tools", 988 "jobserver", ··· 2805 [[package]] 2806 name = "dioxus-primitives" 2807 version = "0.0.1" 2808 + source = "git+https://github.com/DioxusLabs/components#545aa7f55205b488f6403f92133fffb6c66838de" 2809 dependencies = [ 2810 "dioxus 0.7.2", 2811 "dioxus-sdk-time", ··· 5568 5569 [[package]] 5570 name = "jacquard" 5571 + version = "0.9.5" 5572 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5573 dependencies = [ 5574 "bytes", 5575 "getrandom 0.2.16", ··· 5585 "regex", 5586 "regex-lite", 5587 "reqwest", 5588 "serde", 5589 "serde_html_form", 5590 "serde_json", ··· 5599 5600 [[package]] 5601 name = "jacquard-api" 5602 + version = "0.9.5" 5603 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5604 dependencies = [ 5605 "bon", 5606 "bytes", ··· 5618 5619 [[package]] 5620 name = "jacquard-axum" 5621 + version = "0.9.6" 5622 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5623 dependencies = [ 5624 "axum", 5625 "bytes", ··· 5640 5641 [[package]] 5642 name = "jacquard-common" 5643 + version = "0.9.5" 5644 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5645 dependencies = [ 5646 "base64 0.22.1", 5647 "bon", ··· 5668 "regex", 5669 "regex-lite", 5670 "reqwest", 5671 "serde", 5672 "serde_bytes", 5673 "serde_html_form", ··· 5687 5688 [[package]] 5689 name = "jacquard-derive" 5690 + version = "0.9.5" 5691 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5692 dependencies = [ 5693 "heck 0.5.0", 5694 "jacquard-lexicon", ··· 5699 5700 [[package]] 5701 name = "jacquard-identity" 5702 + version = "0.9.5" 5703 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5704 dependencies = [ 5705 "bon", 5706 "bytes", ··· 5710 "jacquard-common", 5711 "jacquard-lexicon", 5712 "miette 7.6.0", 5713 + "mini-moka-wasm", 5714 "n0-future 0.1.3", 5715 "percent-encoding", 5716 "reqwest", 5717 "serde", 5718 "serde_html_form", 5719 "serde_json", ··· 5727 5728 [[package]] 5729 name = "jacquard-lexicon" 5730 + version = "0.9.5" 5731 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5732 dependencies = [ 5733 "cid", 5734 "dashmap 6.1.0", ··· 5753 5754 [[package]] 5755 name = "jacquard-oauth" 5756 + version = "0.9.6" 5757 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5758 dependencies = [ 5759 "base64 0.22.1", 5760 "bytes", ··· 5769 "miette 7.6.0", 5770 "p256", 5771 "rand 0.8.5", 5772 "rouille", 5773 "serde", 5774 "serde_html_form", ··· 5785 5786 [[package]] 5787 name = "jacquard-repo" 5788 + version = "0.9.5" 5789 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 5790 dependencies = [ 5791 "bytes", 5792 "cid", ··· 6478 [[package]] 6479 name = "markdown-weaver" 6480 version = "0.13.0" 6481 + source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#5f4257b7ee3175f73974b43c3269b2130a4479ca" 6482 dependencies = [ 6483 "bitflags 2.10.0", 6484 "getopts", ··· 6491 [[package]] 6492 name = "markdown-weaver-escape" 6493 version = "0.11.0" 6494 + source = "git+https://github.com/rsform/markdown-weaver?branch=para-end-context#5f4257b7ee3175f73974b43c3269b2130a4479ca" 6495 6496 [[package]] 6497 name = "markup5ever" ··· 6743 6744 [[package]] 6745 name = "mini-moka" 6746 + version = "0.11.0" 6747 + source = "git+https://github.com/moka-rs/mini-moka?rev=da864e849f5d034f32e02197fee9bb5d5af36d3d#da864e849f5d034f32e02197fee9bb5d5af36d3d" 6748 dependencies = [ 6749 "crossbeam-channel", 6750 "crossbeam-utils", ··· 6756 ] 6757 6758 [[package]] 6759 + name = "mini-moka-wasm" 6760 + version = "0.10.99" 6761 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#9021d423a2c199294a9206fa3ec2b8b2c261e117" 6762 dependencies = [ 6763 "crossbeam-channel", 6764 "crossbeam-utils", ··· 8026 8027 [[package]] 8028 name = "portable-atomic" 8029 + version = "1.12.0" 8030 source = "registry+https://github.com/rust-lang/crates.io-index" 8031 + checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" 8032 8033 [[package]] 8034 name = "portmapper" ··· 11675 "markdown-weaver", 11676 "markdown-weaver-escape", 11677 "mime-sniffer", 11678 + "mini-moka", 11679 "n0-future 0.1.3", 11680 "postcard", 11681 "regex", ··· 11797 "k256", 11798 "loro", 11799 "miette 7.6.0", 11800 + "mini-moka", 11801 "n0-future 0.1.3", 11802 "rand 0.8.5", 11803 "regex",
+15 -31
crates/weaver-app/assets/styling/entry.css
··· 1 /* Entry page layout - centered content with sidenote margin */ 2 .entry-page { 3 - --content-width: 65ch; 4 --sidenote-width: 14rem; 5 --sidenote-gap: 1.5rem; 6 ··· 39 color: var(--color-primary); 40 } 41 42 - .entry-header .nav-button-prev { 43 - flex-shrink: 1; 44 min-width: 0; 45 } 46 47 - .entry-header .nav-button-next { 48 - flex-shrink: 1; 49 min-width: 0; 50 } 51 52 .entry-header .nav-arrow { ··· 60 text-overflow: ellipsis; 61 } 62 63 - /* Metadata takes center, flexes to fill */ 64 .entry-header .entry-metadata { 65 - flex: 1; 66 - max-width: var(--content-width); 67 margin: 0; 68 padding: 0; 69 border: none; ··· 80 .entry-content-main { 81 width: var(--content-width); 82 max-width: 100%; 83 position: relative; 84 } 85 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 91 /* Footer navigation */ 92 .entry-footer-nav { ··· 295 } 296 } 297 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 */ 307 @media (max-width: 900px) { 308 .entry-header .nav-title { 309 max-width: 8rem; 310 } 311 - 312 - .entry-content-main:has(.sidenote) { 313 - margin-left: -5rem; 314 - } 315 } 316 317 - /* Responsive: mobile - sidenotes go inline */ 318 @media (max-width: 768px) { 319 .entry-header { 320 flex-direction: column; 321 align-items: stretch; 322 gap: 0.5rem; 323 - } 324 - 325 - .entry-content-main:has(.sidenote) { 326 - margin-left: 0; 327 - margin-right: 0; 328 } 329 330 .entry-footer-nav {
··· 1 /* Entry page layout - centered content with sidenote margin */ 2 .entry-page { 3 + --content-width: 95ch; 4 --sidenote-width: 14rem; 5 --sidenote-gap: 1.5rem; 6 ··· 39 color: var(--color-primary); 40 } 41 42 + .entry-header .nav-button-prev, 43 + .entry-header .nav-placeholder:first-child { 44 + flex: 1; 45 min-width: 0; 46 } 47 48 + .entry-header .nav-button-next, 49 + .entry-header .nav-placeholder:last-child { 50 + flex: 1; 51 min-width: 0; 52 + justify-content: flex-end; 53 } 54 55 .entry-header .nav-arrow { ··· 63 text-overflow: ellipsis; 64 } 65 66 + /* Metadata anchored to content width */ 67 .entry-header .entry-metadata { 68 + width: min(var(--content-width), 100%); 69 + flex-shrink: 1; 70 margin: 0; 71 padding: 0; 72 border: none; ··· 83 .entry-content-main { 84 width: var(--content-width); 85 max-width: 100%; 86 + border-top: 1px solid var(--color-border); 87 position: relative; 88 } 89 90 + /* Sidenote layout handled by css.rs body padding */ 91 92 /* Footer navigation */ 93 .entry-footer-nav { ··· 296 } 297 } 298 299 + /* Responsive: narrower */ 300 @media (max-width: 900px) { 301 .entry-header .nav-title { 302 max-width: 8rem; 303 } 304 } 305 306 + /* Responsive: mobile */ 307 @media (max-width: 768px) { 308 .entry-header { 309 flex-direction: column; 310 align-items: stretch; 311 gap: 0.5rem; 312 } 313 314 .entry-footer-nav {
+3 -1
crates/weaver-app/src/components/editor/component.rs
··· 26 use super::toolbar::EditorToolbar; 27 use super::visibility::update_syntax_visibility; 28 #[allow(unused_imports)] 29 - use super::writer::{EditorImageResolver, SyntaxSpanInfo}; 30 use crate::auth::AuthState; 31 use crate::components::collab::CollaboratorAvatars; 32 use crate::components::editor::ReportButton;
··· 26 use super::toolbar::EditorToolbar; 27 use super::visibility::update_syntax_visibility; 28 #[allow(unused_imports)] 29 + use super::writer::EditorImageResolver; 30 + #[allow(unused_imports)] 31 + use super::writer::SyntaxSpanInfo; 32 use crate::auth::AuthState; 33 use crate::components::collab::CollaboratorAvatars; 34 use crate::components::editor::ReportButton;
+22 -2129
crates/weaver-app/src/components/editor/writer.rs
··· 5 //! 6 //! Uses Parser::into_offset_iter() to track gaps between events, which 7 //! represent consumed formatting characters. 8 #[allow(unused_imports)] 9 use super::offset_map::{OffsetMapping, RenderResult}; 10 - use jacquard::types::{ident::AtIdentifier, string::Rkey}; 11 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 std::collections::HashMap; 21 use std::fmt; 22 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 - } 87 88 /// Result of rendering with the EditorWriter. 89 #[derive(Debug, Clone)] ··· 106 pub collected_refs_by_paragraph: Vec<Vec<weaver_common::ExtractedRef>>, 107 } 108 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 /// Tracks the type of wrapper element emitted for WeaverBlock prefix 381 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 382 enum WrapperElement { 383 Aside, 384 Div, 385 } 386 387 /// HTML writer that preserves markdown formatting characters. ··· 476 current_footnote_def: Option<(String, usize, usize)>, 477 478 _phantom: std::marker::PhantomData<&'a ()>, 479 - } 480 - 481 - #[derive(Debug, Clone, Copy)] 482 - enum TableState { 483 - Head, 484 - Body, 485 } 486 487 impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> ··· 756 } 757 } 758 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 /// Generate a unique node ID. 936 /// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`. 937 /// Otherwise produces `n{counter}` for backwards compatibility. ··· 1226 let key = key.trim(); 1227 let value = value.trim(); 1228 if !key.is_empty() && !value.is_empty() { 1229 - attrs.push((CowStr::from(key.to_string()), CowStr::from(value.to_string()))); 1230 } 1231 } else { 1232 // No colon - treat as class, strip leading dot if present ··· 1852 self.weaver_block_buffer.push_str(&text); 1853 } 1854 } 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 Ok(()) 3458 } 3459 }
··· 5 //! 6 //! Uses Parser::into_offset_iter() to track gaps between events, which 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 + 17 #[allow(unused_imports)] 18 use super::offset_map::{OffsetMapping, RenderResult}; 19 use loro::LoroText; 20 + use markdown_weaver::{Alignment, CowStr, Event, WeaverAttributes}; 21 + use markdown_weaver_escape::{StrWrite, escape_html, escape_html_body_text_with_char_count}; 22 use std::collections::HashMap; 23 use std::fmt; 24 use std::ops::Range; 25 + use weaver_common::EntryIndex; 26 27 /// Result of rendering with the EditorWriter. 28 #[derive(Debug, Clone)] ··· 45 pub collected_refs_by_paragraph: Vec<Vec<weaver_common::ExtractedRef>>, 46 } 47 48 /// Tracks the type of wrapper element emitted for WeaverBlock prefix 49 #[derive(Debug, Clone, Copy, PartialEq, Eq)] 50 enum WrapperElement { 51 Aside, 52 Div, 53 + } 54 + 55 + #[derive(Debug, Clone, Copy)] 56 + pub enum TableState { 57 + Head, 58 + Body, 59 } 60 61 /// HTML writer that preserves markdown formatting characters. ··· 150 current_footnote_def: Option<(String, usize, usize)>, 151 152 _phantom: std::marker::PhantomData<&'a ()>, 153 } 154 155 impl<'a, I: Iterator<Item = (Event<'a>, Range<usize>)>, E: EmbedContentProvider, R: ImageResolver> ··· 424 } 425 } 426 427 /// Generate a unique node ID. 428 /// If a prefix is set (paragraph ID), produces `{prefix}-n{counter}`. 429 /// Otherwise produces `n{counter}` for backwards compatibility. ··· 718 let key = key.trim(); 719 let value = value.trim(); 720 if !key.is_empty() && !value.is_empty() { 721 + attrs.push(( 722 + CowStr::from(key.to_string()), 723 + CowStr::from(value.to_string()), 724 + )); 725 } 726 } else { 727 // No colon - treat as class, strip leading dot if present ··· 1347 self.weaver_block_buffer.push_str(&text); 1348 } 1349 } 1350 Ok(()) 1351 } 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 213 tracing::info!("Entry: {book_title} - {title}"); 214 215 - let prev_entry = book_entry_view().prev.clone(); 216 - let next_entry = book_entry_view().next.clone(); 217 - 218 rsx! { 219 EntryOgMeta { 220 title: title.to_string(), ··· 229 div { class: "entry-page", 230 // Header: nav prev + metadata + nav next 231 header { class: "entry-header", 232 - if let Some(ref prev) = prev_entry { 233 NavButton { 234 direction: "prev", 235 entry: prev.entry.clone(), 236 ident: ident(), 237 book_title: book_title() 238 } 239 } 240 241 { ··· 253 } 254 } 255 256 - if let Some(ref next) = next_entry { 257 NavButton { 258 direction: "next", 259 entry: next.entry.clone(), 260 ident: ident(), 261 book_title: book_title() 262 } 263 } 264 } 265 ··· 275 276 // Footer navigation 277 footer { class: "entry-footer-nav", 278 - if let Some(ref prev) = prev_entry { 279 NavButton { 280 direction: "prev", 281 entry: prev.entry.clone(), ··· 284 } 285 } 286 287 - if let Some(ref next) = next_entry { 288 NavButton { 289 direction: "next", 290 entry: next.entry.clone(), ··· 726 727 /// Render some text as markdown. 728 pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element { 729 - let (_res, processed) = crate::data::use_rendered_markdown(props.content, props.ident); 730 #[cfg(feature = "fullstack-server")] 731 _res?; 732
··· 212 213 tracing::info!("Entry: {book_title} - {title}"); 214 215 rsx! { 216 EntryOgMeta { 217 title: title.to_string(), ··· 226 div { class: "entry-page", 227 // Header: nav prev + metadata + nav next 228 header { class: "entry-header", 229 + if let Some(ref prev) = book_entry_view().prev { 230 NavButton { 231 direction: "prev", 232 entry: prev.entry.clone(), 233 ident: ident(), 234 book_title: book_title() 235 } 236 + } else { 237 + div { class: "nav-placeholder" } 238 } 239 240 { ··· 252 } 253 } 254 255 + if let Some(ref next) = book_entry_view().next { 256 NavButton { 257 direction: "next", 258 entry: next.entry.clone(), 259 ident: ident(), 260 book_title: book_title() 261 } 262 + } else { 263 + div { class: "nav-placeholder" } 264 } 265 } 266 ··· 276 277 // Footer navigation 278 footer { class: "entry-footer-nav", 279 + if let Some(ref prev) = book_entry_view().prev { 280 NavButton { 281 direction: "prev", 282 entry: prev.entry.clone(), ··· 285 } 286 } 287 288 + if let Some(ref next) = book_entry_view().next { 289 NavButton { 290 direction: "next", 291 entry: next.entry.clone(), ··· 727 728 /// Render some text as markdown. 729 pub fn EntryMarkdown(props: EntryMarkdownProps) -> Element { 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 + 743 #[cfg(feature = "fullstack-server")] 744 _res?; 745
+4 -4
crates/weaver-app/src/data.rs
··· 421 ) -> (Resource<Option<String>>, Memo<Option<String>>) { 422 let fetcher = use_context::<crate::fetch::Fetcher>(); 423 let fetcher = fetcher.clone(); 424 - let res = use_resource(move || { 425 let fetcher = fetcher.clone(); 426 async move { 427 let entry = content(); ··· 434 435 Some(render_markdown_impl(entry, did, resolved_content).await) 436 } 437 - }); 438 - let memo = use_memo(move || { 439 if let Some(Some(value)) = &*res.read() { 440 Some(value.clone()) 441 } else { 442 None 443 } 444 - }); 445 (res, memo) 446 } 447
··· 421 ) -> (Resource<Option<String>>, Memo<Option<String>>) { 422 let fetcher = use_context::<crate::fetch::Fetcher>(); 423 let fetcher = fetcher.clone(); 424 + let res = use_resource(use_reactive!(|(content, ident)| { 425 let fetcher = fetcher.clone(); 426 async move { 427 let entry = content(); ··· 434 435 Some(render_markdown_impl(entry, did, resolved_content).await) 436 } 437 + })); 438 + let memo = use_memo(use_reactive!(|res| { 439 if let Some(Some(value)) = &*res.read() { 440 Some(value.clone()) 441 } else { 442 None 443 } 444 + })); 445 (res, memo) 446 } 447
+22 -8
crates/weaver-index/src/endpoints/notebook.rs
··· 139 .maybe_path(non_empty_cowstr(&notebook_row.path)) 140 .build(); 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() { 145 let entry_uri = AtUri::new(&entry_row.uri).map_err(|e| { 146 tracing::error!("Invalid entry URI in db: {}", e); 147 XrpcErrorResponse::internal_error("Invalid URI stored") ··· 185 .maybe_path(non_empty_cowstr(&entry_row.path)) 186 .build(); 187 188 - let book_entry = BookEntryView::new() 189 - .entry(entry_view) 190 - .index(idx as i64) 191 - .build(); 192 193 - entries.push(book_entry); 194 } 195 196 // Build cursor for pagination (position-based)
··· 139 .maybe_path(non_empty_cowstr(&notebook_row.path)) 140 .build(); 141 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 let entry_uri = AtUri::new(&entry_row.uri).map_err(|e| { 146 tracing::error!("Invalid entry URI in db: {}", e); 147 XrpcErrorResponse::internal_error("Invalid URI stored") ··· 185 .maybe_path(non_empty_cowstr(&entry_row.path)) 186 .build(); 187 188 + entry_views.push(entry_view); 189 + } 190 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 + ); 208 } 209 210 // Build cursor for pagination (position-based)
+9 -8
crates/weaver-renderer/src/css.rs
··· 322 color: var(--color-primary); 323 }} 324 325 - /* Aside blocks (via WeaverBlock prefix) */ 326 - aside, .aside {{ 327 float: left; 328 width: 40%; 329 margin: 0 1.5rem 1rem 0; ··· 334 clear: left; 335 }} 336 337 - aside > *:first-child, 338 - .aside > *:first-child {{ 339 margin-top: 0; 340 }} 341 342 - aside > *:last-child, 343 - .aside > *:last-child {{ 344 margin-bottom: 0; 345 }} 346 347 /* Reset blockquote styling inside asides */ 348 - aside > blockquote, 349 - .aside > blockquote {{ 350 border-left: none; 351 background: transparent; 352 padding: 0;
··· 322 color: var(--color-primary); 323 }} 324 325 + /* Aside blocks (via WeaverBlock prefix) - scoped to notebook content */ 326 + .notebook-content aside, 327 + .notebook-content .aside {{ 328 float: left; 329 width: 40%; 330 margin: 0 1.5rem 1rem 0; ··· 335 clear: left; 336 }} 337 338 + .notebook-content aside > *:first-child, 339 + .notebook-content .aside > *:first-child {{ 340 margin-top: 0; 341 }} 342 343 + .notebook-content aside > *:last-child, 344 + .notebook-content .aside > *:last-child {{ 345 margin-bottom: 0; 346 }} 347 348 /* Reset blockquote styling inside asides */ 349 + .notebook-content aside > blockquote, 350 + .notebook-content .aside > blockquote {{ 351 border-left: none; 352 background: transparent; 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>