···43 self.end.saturating_sub(self.start)
44 }
45046 pub fn is_empty(&self) -> bool {
47 self.len() == 0
48 }
···65/// These represent semantic operations on the document, decoupled from
66/// how they're triggered (keyboard, mouse, touch, voice, etc.).
67#[derive(Debug, Clone, PartialEq)]
068pub enum EditorAction {
69 // === Text Insertion ===
70 /// Insert text at the given range (replacing any selected content).
···178/// SmolStr to efficiently handle both single characters and composed sequences
179/// (from dead keys, IME, etc.).
180#[derive(Debug, Clone, PartialEq, Eq, Hash)]
0181pub enum Key {
182 /// A character key. The string corresponds to the character typed,
183 /// taking into account locale, modifiers, and keyboard mapping.
···460 pub super_: bool, // `super` is a keyword
461}
4620463impl Modifiers {
464 pub const NONE: Self = Self {
465 ctrl: false,
···43 self.end.saturating_sub(self.start)
44 }
4546+ #[allow(dead_code)]
47 pub fn is_empty(&self) -> bool {
48 self.len() == 0
49 }
···66/// These represent semantic operations on the document, decoupled from
67/// how they're triggered (keyboard, mouse, touch, voice, etc.).
68#[derive(Debug, Clone, PartialEq)]
69+#[allow(dead_code)]
70pub enum EditorAction {
71 // === Text Insertion ===
72 /// Insert text at the given range (replacing any selected content).
···180/// SmolStr to efficiently handle both single characters and composed sequences
181/// (from dead keys, IME, etc.).
182#[derive(Debug, Clone, PartialEq, Eq, Hash)]
183+#[allow(dead_code)]
184pub enum Key {
185 /// A character key. The string corresponds to the character typed,
186 /// taking into account locale, modifiers, and keyboard mapping.
···463 pub super_: bool, // `super` is a keyword
464}
465466+#[allow(dead_code)]
467impl Modifiers {
468 pub const NONE: Self = Self {
469 ctrl: false,
···98}
99100/// Get all captured log entries as a single string.
0101pub fn get_logs() -> String {
102 LOG_BUFFER.with(|buf| {
103 let buf = buf.borrow();
···98}
99100/// Get all captured log entries as a single string.
101+#[allow(dead_code)]
102pub fn get_logs() -> String {
103 LOG_BUFFER.with(|buf| {
104 let buf = buf.borrow();
···114///
115/// Returns the mapping and whether the cursor should snap to the next
116/// visible position (for invisible content).
0117pub fn find_mapping_for_char(
118 offset_map: &[OffsetMapping],
119 char_offset: usize,
···162163/// Result of finding a valid cursor position.
164#[derive(Debug, Clone)]
0165pub struct SnappedPosition<'a> {
166 pub mapping: &'a OffsetMapping,
167 pub offset_in_mapping: usize,
168 pub snapped: Option<SnapDirection>,
169}
1700171impl SnappedPosition<'_> {
172 /// Get the absolute char offset for this position.
173 pub fn char_offset(&self) -> usize {
···181/// If the position is already valid, returns it directly. Otherwise,
182/// searches in the preferred direction first, falling back to the other
183/// direction if needed.
0184pub fn find_nearest_valid_position(
185 offset_map: &[OffsetMapping],
186 char_offset: usize,
···219}
220221/// Search for a valid position in a specific direction.
0222fn find_valid_in_direction(
223 offset_map: &[OffsetMapping],
224 char_offset: usize,
···275}
276277/// Check if a char offset is at a valid (non-invisible) cursor position.
0278pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool {
279 find_mapping_for_char(offset_map, char_offset)
280 .map(|(m, should_snap)| !should_snap && m.utf16_len > 0)
···473 let mappings = make_test_mappings();
474475 // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5)
476- let pos = find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap();
0477 assert_eq!(pos.char_offset(), 5); // end of "alt" mapping
478 assert_eq!(pos.snapped, Some(SnapDirection::Backward));
479 }
···114///
115/// Returns the mapping and whether the cursor should snap to the next
116/// visible position (for invisible content).
117+#[allow(dead_code)]
118pub fn find_mapping_for_char(
119 offset_map: &[OffsetMapping],
120 char_offset: usize,
···163164/// Result of finding a valid cursor position.
165#[derive(Debug, Clone)]
166+#[allow(dead_code)]
167pub struct SnappedPosition<'a> {
168 pub mapping: &'a OffsetMapping,
169 pub offset_in_mapping: usize,
170 pub snapped: Option<SnapDirection>,
171}
172173+#[allow(dead_code)]
174impl SnappedPosition<'_> {
175 /// Get the absolute char offset for this position.
176 pub fn char_offset(&self) -> usize {
···184/// If the position is already valid, returns it directly. Otherwise,
185/// searches in the preferred direction first, falling back to the other
186/// direction if needed.
187+#[allow(dead_code)]
188pub fn find_nearest_valid_position(
189 offset_map: &[OffsetMapping],
190 char_offset: usize,
···223}
224225/// Search for a valid position in a specific direction.
226+#[allow(dead_code)]
227fn find_valid_in_direction(
228 offset_map: &[OffsetMapping],
229 char_offset: usize,
···280}
281282/// Check if a char offset is at a valid (non-invisible) cursor position.
283+#[allow(dead_code)]
284pub fn is_valid_cursor_position(offset_map: &[OffsetMapping], char_offset: usize) -> bool {
285 find_mapping_for_char(offset_map, char_offset)
286 .map(|(m, should_snap)| !should_snap && m.utf16_len > 0)
···479 let mappings = make_test_mappings();
480481 // Position 10 is invisible (in 5..15), prefer backward to end of "alt" (position 5)
482+ let pos =
483+ find_nearest_valid_position(&mappings, 10, Some(SnapDirection::Backward)).unwrap();
484 assert_eq!(pos.char_offset(), 5); // end of "alt" mapping
485 assert_eq!(pos.snapped, Some(SnapDirection::Backward));
486 }
···124}
125126/// Check if cursor is in the same paragraph as a syntax span.
0127fn cursor_in_same_paragraph(
128 cursor_offset: usize,
129 syntax_range: &Range<usize>,
···124}
125126/// Check if cursor is in the same paragraph as a syntax span.
127+#[allow(dead_code)]
128fn cursor_in_same_paragraph(
129 cursor_offset: usize,
130 syntax_range: &Range<usize>,
+351-32
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-9use super::offset_map::{OffsetMapping, RenderResult};
10use jacquard::types::{ident::AtIdentifier, string::Rkey};
11use loro::LoroText;
12use markdown_weaver::{
13 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
014};
15use markdown_weaver_escape::{
16 StrWrite, escape_href, escape_html, escape_html_body_text,
···30 segments: Vec<String>,
31}
32033impl SegmentedWriter {
34 pub fn new() -> Self {
35 Self {
···375 }
376}
3770000000378/// HTML writer that preserves markdown formatting characters.
379///
380/// This writer processes offset-iter events to detect gaps (consumed formatting)
···416417 // Offset mapping tracking - current paragraph
418 offset_maps: Vec<OffsetMapping>,
419- node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs
420- auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value
421 static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index
422- current_paragraph_index: usize, // which paragraph we're currently building (0-indexed)
423 next_node_id: usize,
424 current_node_id: Option<String>, // node ID for current text container
425 current_node_char_offset: usize, // UTF-16 offset within current node
···450 syntax_spans_by_para: Vec<Vec<SyntaxSpanInfo>>,
451 refs_by_para: Vec<Vec<weaver_common::ExtractedRef>>,
4520000000000000000453 _phantom: std::marker::PhantomData<&'a ()>,
454}
455···519 offset_maps_by_para: Vec::new(),
520 syntax_spans_by_para: Vec::new(),
521 refs_by_para: Vec::new(),
000000522 _phantom: std::marker::PhantomData,
523 }
524 }
···578 offset_maps_by_para: self.offset_maps_by_para,
579 syntax_spans_by_para: self.syntax_spans_by_para,
580 refs_by_para: self.refs_by_para,
000000581 _phantom: std::marker::PhantomData,
582 }
583 }
···608 }
609610 /// Get the next paragraph ID that would be assigned (for tracking allocations).
0611 pub fn next_paragraph_id(&self) -> Option<usize> {
612 self.auto_increment_prefix
613 }
···1106 }
11071108 // Consume raw text events until end tag, for alt attributes
01109 fn raw_text(&mut self) -> Result<(), fmt::Error> {
1110 use Event::*;
1111 let mut nest = 0;
···1170 }
1171 }
1172000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001173 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), fmt::Error> {
1174 use Event::*;
1175···1546 escape_html(&mut self.writer, spaces)?;
1547 self.write("</span>")?;
15481549- // Record syntax span info
1550- // self.syntax_spans.push(SyntaxSpanInfo {
1551- // syn_id,
1552- // char_range: char_start..char_end,
1553- // syntax_type: SyntaxType::Inline,
1554- // formatted_range: None,
1555- // });
1556-1557 // Count this span as a child
1558 self.current_node_child_count += 1;
1559···15701571 // After <br>, emit plain zero-width space for cursor positioning
1572 self.write(" ")?;
1573- //self.write("\u{200B}")?;
15741575 // Count the zero-width space text node as a child
1576 self.current_node_child_count += 1;
···1636 self.write("<div class=\"toggle-block\"><hr /></div>\n")?;
1637 }
1638 FootnoteReference(name) => {
01639 let len = self.numbers.len() + 1;
1640- self.write("<sup class=\"footnote-reference\"><a href=\"#")?;
1641- escape_html(&mut self.writer, &name)?;
1642- self.write("\">")?;
1643 let number = *self.numbers.entry(name.to_string()).or_insert(len);
1644- write!(&mut self.writer, "{}", number)?;
1645- self.write("</a></sup>")?;
000000000000000000000000000000000000000001646 }
1647 TaskListMarker(checked) => {
1648 // Emit the [ ] or [x] syntax
···1680 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?;
1681 }
1682 }
1683- WeaverBlock(_) => {}
0001684 }
1685 Ok(())
1686 }
···18301831 Ok(())
1832 }
1833- Tag::Paragraph => {
0001834 // Record paragraph start for boundary tracking
1835 // BUT skip if inside a list - list owns the paragraph boundary
1836 if self.list_depth == 0 {
···1892 classes,
1893 attrs,
1894 } => {
0001895 // Record paragraph start for boundary tracking
1896 // Treat headings as paragraph-level blocks
1897 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
···1980 self.in_non_writing_block = true; // Suppress content output
1981 Ok(())
1982 } else {
01983 self.table_alignments = alignments;
1984 self.write("<table>")
1985 }
···2018 }
2019 }
2020 Tag::BlockQuote(kind) => {
002021 let class_str = match kind {
2022 None => "",
2023 Some(BlockQuoteKind::Note) => " class=\"markdown-alert-note\"",
···2037 Ok(())
2038 }
2039 Tag::CodeBlock(info) => {
002040 // Track code block as paragraph-level block
2041 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
2042···2110 }
2111 }
2112 Tag::List(Some(1)) => {
02113 // Track list as paragraph-level block
2114 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
2115 self.list_depth += 1;
···2120 }
2121 }
2122 Tag::List(Some(start)) => {
02123 // Track list as paragraph-level block
2124 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
2125 self.list_depth += 1;
···2132 self.write("\">\n")
2133 }
2134 Tag::List(None) => {
02135 // Track list as paragraph-level block
2136 self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
2137 self.list_depth += 1;
···2239 Ok(())
2240 }
2241 Tag::DefinitionList => {
02242 if self.end_newline {
2243 self.write("<dl>\n")
2244 } else {
···2507 id,
2508 attrs,
2509 } => self.write_embed(range, embed_type, dest_url, title, id, attrs),
2510- Tag::WeaverBlock(_, _) => {
2511 self.in_non_writing_block = true;
0000002512 Ok(())
2513 }
2514 Tag::FootnoteDefinition(name) => {
2515- if self.end_newline {
2516- self.write("<div class=\"footnote-definition\" id=\"")?;
2517- } else {
2518- self.write("\n<div class=\"footnote-definition\" id=\"")?;
0000002519 }
2520- escape_html(&mut self.writer, &name)?;
2521- self.write("\"><sup class=\"footnote-definition-label\">")?;
00000000000000000000000000000000002522 let len = self.numbers.len() + 1;
2523 let number = *self.numbers.entry(name.to_string()).or_insert(len);
2524- write!(&mut self.writer, "{}", number)?;
2525- self.write("</sup>")
000002526 }
2527 Tag::MetadataBlock(_) => {
2528 self.in_non_writing_block = true;
···2566 }
2567 Ok(())
2568 }
2569- TagEnd::Paragraph => {
2570 // Capture paragraph boundary info BEFORE writing closing HTML
2571 // Skip if inside a list - list owns the paragraph boundary
2572 let para_boundary = if self.list_depth == 0 {
···2585 // Write closing HTML to current segment
2586 self.end_node();
2587 self.write("</p>\n")?;
025882589 // Now finalize paragraph (starts new segment)
2590 if let Some((byte_range, char_range)) = para_boundary {
···2609 self.write("</")?;
2610 write!(&mut self.writer, "{}", level)?;
2611 self.write(">\n")?;
026122613 // Now finalize paragraph (starts new segment)
2614 if let Some((byte_range, char_range)) = para_boundary {
···2701 }
2702 }
2703 self.write("</blockquote>\n")?;
027042705 // Now finalize paragraph if we had one
2706 if let Some((byte_range, char_range)) = para_boundary {
···2857 });
28582859 self.write("</ol>\n")?;
028602861 // Finalize paragraph after closing HTML
2862 if let Some((byte_range, char_range)) = para_boundary {
···2878 });
28792880 self.write("</ul>\n")?;
028812882 // Finalize paragraph after closing HTML
2883 if let Some((byte_range, char_range)) = para_boundary {
···2889 self.end_node();
2890 self.write("</li>\n")
2891 }
2892- TagEnd::DefinitionList => self.write("</dl>\n"),
0002893 TagEnd::DefinitionListTitle => {
2894 self.end_node();
2895 self.write("</dt>\n")
···2956 TagEnd::Embed => Ok(()),
2957 TagEnd::WeaverBlock(_) => {
2958 self.in_non_writing_block = false;
000000000000000000000000000000000000000000000002959 Ok(())
2960 }
2961- TagEnd::FootnoteDefinition => self.write("</div>\n"),
00000000000000000000000000002962 TagEnd::MetadataBlock(_) => {
2963 self.in_non_writing_block = false;
2964 Ok(())
···5//!
6//! Uses Parser::into_offset_iter() to track gaps between events, which
7//! represent consumed formatting characters.
8+#[allow(unused_imports)]
9use super::offset_map::{OffsetMapping, RenderResult};
10use jacquard::types::{ident::AtIdentifier, string::Rkey};
11use loro::LoroText;
12use markdown_weaver::{
13 Alignment, BlockQuoteKind, CodeBlockKind, CowStr, EmbedType, Event, LinkType, Tag,
14+ WeaverAttributes,
15};
16use markdown_weaver_escape::{
17 StrWrite, escape_href, escape_html, escape_html_body_text,
···31 segments: Vec<String>,
32}
3334+#[allow(dead_code)]
35impl SegmentedWriter {
36 pub fn new() -> Self {
37 Self {
···377 }
378}
379380+/// 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.
388///
389/// This writer processes offset-iter events to detect gaps (consumed formatting)
···425426 // Offset mapping tracking - current paragraph
427 offset_maps: Vec<OffsetMapping>,
428+ node_id_prefix: Option<String>, // paragraph ID prefix for stable node IDs
429+ auto_increment_prefix: Option<usize>, // if set, auto-increment prefix per paragraph from this value
430 static_prefix_override: Option<(usize, String)>, // (index, prefix) - override auto-increment at this index
431+ current_paragraph_index: usize, // which paragraph we're currently building (0-indexed)
432 next_node_id: usize,
433 current_node_id: Option<String>, // node ID for current text container
434 current_node_char_offset: usize, // UTF-16 offset within current node
···459 syntax_spans_by_para: Vec<Vec<SyntaxSpanInfo>>,
460 refs_by_para: Vec<Vec<weaver_common::ExtractedRef>>,
461462+ // WeaverBlock prefix system
463+ /// Pending WeaverBlock attrs to apply to the next block element
464+ pending_block_attrs: Option<WeaverAttributes<'static>>,
465+ /// Type of wrapper element currently open (needs closing on block end)
466+ active_wrapper: Option<WrapperElement>,
467+ /// Buffer for WeaverBlock text content (to parse for attrs)
468+ weaver_block_buffer: String,
469+ /// Start char offset of current WeaverBlock (for syntax span)
470+ weaver_block_char_start: Option<usize>,
471+472+ // Footnote syntax linking (ref ↔ definition visibility)
473+ /// Maps footnote name → (syntax_span_index, char_start) for linking ref and def
474+ footnote_ref_spans: HashMap<String, (usize, usize)>,
475+ /// Current footnote definition being processed (name, syntax_span_index, char_start)
476+ current_footnote_def: Option<(String, usize, usize)>,
477+478 _phantom: std::marker::PhantomData<&'a ()>,
479}
480···544 offset_maps_by_para: Vec::new(),
545 syntax_spans_by_para: Vec::new(),
546 refs_by_para: Vec::new(),
547+ pending_block_attrs: None,
548+ active_wrapper: None,
549+ weaver_block_buffer: String::new(),
550+ weaver_block_char_start: None,
551+ footnote_ref_spans: HashMap::new(),
552+ current_footnote_def: None,
553 _phantom: std::marker::PhantomData,
554 }
555 }
···609 offset_maps_by_para: self.offset_maps_by_para,
610 syntax_spans_by_para: self.syntax_spans_by_para,
611 refs_by_para: self.refs_by_para,
612+ pending_block_attrs: self.pending_block_attrs,
613+ active_wrapper: self.active_wrapper,
614+ weaver_block_buffer: self.weaver_block_buffer,
615+ weaver_block_char_start: self.weaver_block_char_start,
616+ footnote_ref_spans: self.footnote_ref_spans,
617+ current_footnote_def: self.current_footnote_def,
618 _phantom: std::marker::PhantomData,
619 }
620 }
···645 }
646647 /// Get the next paragraph ID that would be assigned (for tracking allocations).
648+ #[allow(dead_code)]
649 pub fn next_paragraph_id(&self) -> Option<usize> {
650 self.auto_increment_prefix
651 }
···1144 }
11451146 // Consume raw text events until end tag, for alt attributes
1147+ #[allow(dead_code)]
1148 fn raw_text(&mut self) -> Result<(), fmt::Error> {
1149 use Event::*;
1150 let mut nest = 0;
···1209 }
1210 }
12111212+ /// Parse WeaverBlock text content into attributes.
1213+ /// Format: comma-separated, colon for key:value, otherwise class.
1214+ /// Example: ".aside, width: 300px" -> classes: ["aside"], attrs: [("width", "300px")]
1215+ fn parse_weaver_attrs(text: &str) -> WeaverAttributes<'static> {
1216+ let mut classes = Vec::new();
1217+ let mut attrs = Vec::new();
1218+1219+ for part in text.split(',') {
1220+ let part = part.trim();
1221+ if part.is_empty() {
1222+ continue;
1223+ }
1224+1225+ if let Some((key, value)) = part.split_once(':') {
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
1233+ let class = part.strip_prefix('.').unwrap_or(part);
1234+ if !class.is_empty() {
1235+ classes.push(CowStr::from(class.to_string()));
1236+ }
1237+ }
1238+ }
1239+1240+ WeaverAttributes { classes, attrs }
1241+ }
1242+1243+ /// Emit wrapper element start based on pending block attrs.
1244+ /// Returns true if a wrapper was emitted.
1245+ fn emit_wrapper_start(&mut self) -> Result<bool, fmt::Error> {
1246+ if let Some(attrs) = self.pending_block_attrs.take() {
1247+ let is_aside = attrs.classes.iter().any(|c| c.as_ref() == "aside");
1248+1249+ if !self.end_newline {
1250+ self.write("\n")?;
1251+ }
1252+1253+ if is_aside {
1254+ self.write("<aside")?;
1255+ self.active_wrapper = Some(WrapperElement::Aside);
1256+ } else {
1257+ self.write("<div")?;
1258+ self.active_wrapper = Some(WrapperElement::Div);
1259+ }
1260+1261+ // Write classes (excluding "aside" if using <aside> element)
1262+ let classes: Vec<_> = if is_aside {
1263+ attrs
1264+ .classes
1265+ .iter()
1266+ .filter(|c| c.as_ref() != "aside")
1267+ .collect()
1268+ } else {
1269+ attrs.classes.iter().collect()
1270+ };
1271+1272+ if !classes.is_empty() {
1273+ self.write(" class=\"")?;
1274+ for (i, class) in classes.iter().enumerate() {
1275+ if i > 0 {
1276+ self.write(" ")?;
1277+ }
1278+ escape_html(&mut self.writer, class)?;
1279+ }
1280+ self.write("\"")?;
1281+ }
1282+1283+ // Write other attrs
1284+ for (attr, value) in &attrs.attrs {
1285+ self.write(" ")?;
1286+ escape_html(&mut self.writer, attr)?;
1287+ self.write("=\"")?;
1288+ escape_html(&mut self.writer, value)?;
1289+ self.write("\"")?;
1290+ }
1291+1292+ self.write(">\n")?;
1293+ Ok(true)
1294+ } else {
1295+ Ok(false)
1296+ }
1297+ }
1298+1299+ /// Close active wrapper element if one is open
1300+ fn close_wrapper(&mut self) -> Result<(), fmt::Error> {
1301+ if let Some(wrapper) = self.active_wrapper.take() {
1302+ match wrapper {
1303+ WrapperElement::Aside => self.write("</aside>\n")?,
1304+ WrapperElement::Div => self.write("</div>\n")?,
1305+ }
1306+ }
1307+ Ok(())
1308+ }
1309+1310 fn process_event(&mut self, event: Event<'_>, range: Range<usize>) -> Result<(), fmt::Error> {
1311 use Event::*;
1312···1683 escape_html(&mut self.writer, spaces)?;
1684 self.write("</span>")?;
1685000000001686 // Count this span as a child
1687 self.current_node_child_count += 1;
1688···16991700 // After <br>, emit plain zero-width space for cursor positioning
1701 self.write(" ")?;
017021703 // Count the zero-width space text node as a child
1704 self.current_node_child_count += 1;
···1764 self.write("<div class=\"toggle-block\"><hr /></div>\n")?;
1765 }
1766 FootnoteReference(name) => {
1767+ // Get/create footnote number
1768 let len = self.numbers.len() + 1;
0001769 let number = *self.numbers.entry(name.to_string()).or_insert(len);
1770+1771+ // Emit the [^name] syntax as a hideable syntax span
1772+ let raw_text = &self.source[range.clone()];
1773+ let char_start = self.last_char_offset;
1774+ let syntax_char_len = raw_text.chars().count();
1775+ let char_end = char_start + syntax_char_len;
1776+ let syn_id = self.gen_syn_id();
1777+1778+ write!(
1779+ &mut self.writer,
1780+ "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\" spellcheck=\"false\">",
1781+ syn_id, char_start, char_end
1782+ )?;
1783+ escape_html(&mut self.writer, raw_text)?;
1784+ self.write("</span>")?;
1785+1786+ // Track this span for linking with the footnote definition later
1787+ let span_index = self.syntax_spans.len();
1788+ self.syntax_spans.push(SyntaxSpanInfo {
1789+ syn_id,
1790+ char_range: char_start..char_end,
1791+ syntax_type: SyntaxType::Inline,
1792+ formatted_range: None, // Set when we see the definition
1793+ });
1794+ self.footnote_ref_spans
1795+ .insert(name.to_string(), (span_index, char_start));
1796+1797+ // Record offset mapping for the syntax span content
1798+ self.record_mapping(range.clone(), char_start..char_end);
1799+1800+ // Count as child
1801+ self.current_node_child_count += 1;
1802+1803+ // Emit the visible footnote reference (superscript number)
1804+ write!(
1805+ &mut self.writer,
1806+ "<sup class=\"footnote-reference\"><a href=\"#fn-{}\">{}</a></sup>",
1807+ name, number
1808+ )?;
1809+1810+ // Update tracking
1811+ self.last_char_offset = char_end;
1812+ self.last_byte_offset = range.end;
1813 }
1814 TaskListMarker(checked) => {
1815 // Emit the [ ] or [x] syntax
···1847 self.write("<input disabled=\"\" type=\"checkbox\"/>\n")?;
1848 }
1849 }
1850+ WeaverBlock(text) => {
1851+ // Buffer WeaverBlock content for parsing on End
1852+ self.weaver_block_buffer.push_str(&text);
1853+ }
1854 }
1855 Ok(())
1856 }
···20002001 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 {
···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));
···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 }
···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\"",
···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···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;
···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;
···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;
···2423 Ok(())
2424 }
2425 Tag::DefinitionList => {
2426+ self.emit_wrapper_start()?;
2427 if self.end_newline {
2428 self.write("<dl>\n")
2429 } else {
···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;
···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 {
···2821 // Write closing HTML to current segment
2822 self.end_node();
2823 self.write("</p>\n")?;
2824+ self.close_wrapper()?;
28252826 // Now finalize paragraph (starts new segment)
2827 if let Some((byte_range, char_range)) = para_boundary {
···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
28502851 // Now finalize paragraph (starts new segment)
2852 if let Some((byte_range, char_range)) = para_boundary {
···2939 }
2940 }
2941 self.write("</blockquote>\n")?;
2942+ self.close_wrapper()?;
29432944 // Now finalize paragraph if we had one
2945 if let Some((byte_range, char_range)) = para_boundary {
···3096 });
30973098 self.write("</ol>\n")?;
3099+ self.close_wrapper()?;
31003101 // Finalize paragraph after closing HTML
3102 if let Some((byte_range, char_range)) = para_boundary {
···3118 });
31193120 self.write("</ul>\n")?;
3121+ self.close_wrapper()?;
31223123 // Finalize paragraph after closing HTML
3124 if let Some((byte_range, char_range)) = para_boundary {
···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")
···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(())
+49-20
crates/weaver-app/src/components/entry.rs
···212213 tracing::info!("Entry: {book_title} - {title}");
214000215 rsx! {
216 EntryOgMeta {
217 title: title.to_string(),
···223 }
224 document::Link { rel: "stylesheet", href: ENTRY_CSS }
225226- div { class: "entry-page-layout",
227- // Left gutter with prev button
228- if let Some(ref prev) = book_entry_view().prev {
229- div { class: "nav-gutter nav-prev",
230 NavButton {
231 direction: "prev",
232 entry: prev.entry.clone(),
···234 book_title: book_title()
235 }
236 }
237- }
238239- // Main content area
240- div { class: "entry-content-main notebook-content",
241- // Metadata header
242 {
243 let (word_count, reading_time_mins) = calculate_reading_stats(&entry_record().content);
244 rsx! {
···254 }
255 }
256257- // Rendered markdown
258- EntryMarkdown {
259- content: entry_record,
260- ident
0000000000000261 }
262 }
263264- // Right gutter with next button
265- if let Some(ref next) = book_entry_view().next {
266- div { class: "nav-gutter nav-next",
000000000267 NavButton {
268 direction: "next",
269 entry: next.entry.clone(),
···648 }
649}
650651-/// Navigation button for prev/next entries
652#[component]
653pub fn NavButton(
654 direction: &'static str,
···662 .map(|t| t.as_ref())
663 .unwrap_or("Untitled");
664665- // Get path from view for URL, fallback to title
666 let entry_path = entry
667 .path
668 .as_ref()
669 .map(|p| p.as_ref().to_string())
670 .unwrap_or_else(|| entry_title.to_string());
671672- let arrow = if direction == "prev" { "←" } else { "→" };
0000673674 rsx! {
675 Link {
···679 title: entry_path.into()
680 },
681 class: "nav-button nav-button-{direction}",
682- div { class: "nav-arrow", "{arrow}" }
683- div { class: "nav-title", "{entry_title}" }
00000684 }
685 }
686}
···22pub use preprocess::AtProtoPreprocessContext;
23pub use types::{BlobInfo, BlobName};
24pub use writer::{ClientWriter, EmbedContentProvider};
000
···22pub use preprocess::AtProtoPreprocessContext;
23pub use types::{BlobInfo, BlobName};
24pub use writer::{ClientWriter, EmbedContentProvider};
25+26+#[cfg(test)]
27+mod tests;
···1+---
2+source: crates/weaver-renderer/src/atproto/tests.rs
3+expression: output
4+---
5+<p>This is a paragraph.</p>
6+<p>This is another paragraph.</p>
···1+---
2+source: crates/weaver-renderer/src/atproto/tests.rs
3+expression: output
4+---
5+<aside>
6+<p>This paragraph should be in an aside.</p>
7+</aside>
···1+---
2+source: crates/weaver-renderer/src/atproto/tests.rs
3+expression: output
4+---
5+<aside>
6+<h2>Heading in aside</h2>
7+<p>Paragraph also in aside.</p>
8+</aside>
···1+---
2+source: crates/weaver-renderer/src/static_site/tests.rs
3+expression: output
4+---
5+<aside>
6+<p>This paragraph should be in an aside.</p>
7+</aside>
···1+---
2+source: crates/weaver-renderer/src/static_site/tests.rs
3+expression: output
4+---
5+<aside>
6+<h2>Heading in aside</h2>
7+<p>Paragraph also in aside.</p>
8+</aside>
···1+# Weaver: Long-form Writing on AT Protocol
2+3+*Or: "Get in kid, we're rebuilding the blogosphere!"*
4+5+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.[^nostalgia]
6+[^nostalgia]: Hi Rahaeli. Sorry I was the wrong kind of nerd.
7+8+> ![[weaver_photo_med.jpg]]*The namesake of what I'm building*
9+10+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.
11+12+## The Blogosphere
13+14+I am an atheist in large part because of a blog called Common Sense Atheism.[^csa] 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.
15+[^csa]: The author, Luke Muehlhauser, was criticising both Richard Dawkins *and* some Christian apologetics I was familiar with.
16+17+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,[^ozy] a blog by Ozy Frantz, a transmasc person in the broader rationalist and Effective Altruist blogosphere.
18+[^ozy]: Specifically their piece on the [cluster structure of genderspace](https://thingofthings.wordpress.com/2017/05/05/the-cluster-structure-of-genderspace/).
19+20+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.[^irony]
21+[^irony]: Amusingly I now think that being on Twitter and now Bluesky made me a better writer. Restrictions breed creativity, after all.
22+23+{.aside}
24+> **On Platform Decay**
25+>
26+> 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.
27+28+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.
29+30+Plus, I'm loathe to enable a centralised platform like Substack where the owners are unfortunately friendly to fascists.[^substack]
31+[^substack]: I am very much a fan of freedom of expression. I'm not so much a fan of paying money to Nazis.
32+33+That's where the `at://` protocol and Weaver comes in.
34+35+## The Pitch
36+37+Weaver is designed to be a highly flexible platform for medium and long-form writing on atproto.[^namesake] 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.
38+[^namesake]: The weaver bird builds intricate, self-contained homes—seemed fitting for a platform about owning your writing.
39+40+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.
41+42+{.aside}
43+> **The Ultimate Goal**
44+>
45+> 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 `at://` protocol.
46+47+## How It Works
48+49+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.
50+51+You own what you write.[^ownership] 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.
52+[^ownership]: Technically you can include entries you don't control in your notebooks, although this isn't a supported mode—it's about *your* ownership of *your* words.
53+54+Entries are Markdown text—specifically, an extension on the Obsidian flavour of Markdown.[^markdown] They support additional embed types, including atproto record embeds and other markdown documents, as well as resizable images.
55+[^markdown]: I forked the popular rust markdown processing library `pulldown-cmark` because it had limited extensibility along the axes I wanted—custom syntax extensions to support Obsidian's Markdown flavour and additional useful features, like some of the ones on show here!
56+57+## Why Rust?
58+59+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.
60+61+{.aside}
62+> **On Interoperability**
63+>
64+> The `at://` 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.
65+66+## Evolution
67+68+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.
69+70+If I screw this up, not too hard for someone else to pick up the torch and continue.[^open]
71+[^open]: 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.