atproto blogging
1//! Syntax span emission methods for EditorWriter.
2
3use core::fmt;
4use std::ops::Range;
5
6use markdown_weaver::Event;
7use markdown_weaver_escape::{StrWrite, escape_html};
8
9use crate::offset_map::OffsetMapping;
10use crate::render::{EmbedContentProvider, ImageResolver, WikilinkValidator};
11use crate::syntax::{SyntaxSpanInfo, SyntaxType, classify_syntax};
12
13use super::EditorWriter;
14
15impl<'a, T, I, E, R, W> EditorWriter<'a, T, I, E, R, W>
16where
17 T: crate::TextBuffer,
18 I: Iterator<Item = (Event<'a>, Range<usize>)>,
19 E: EmbedContentProvider,
20 R: ImageResolver,
21 W: WikilinkValidator,
22{
23 /// Emit syntax span for a given range and record offset mapping.
24 pub(crate) fn emit_syntax(&mut self, range: Range<usize>) -> Result<(), fmt::Error> {
25 if range.start < range.end {
26 let syntax = &self.source[range.clone()];
27 if !syntax.is_empty() {
28 let char_start = self.last_char_offset;
29 let syntax_char_len = syntax.chars().count();
30 let char_end = char_start + syntax_char_len;
31
32 tracing::trace!(
33 target: "weaver::writer",
34 byte_range = ?range,
35 char_range = ?(char_start..char_end),
36 syntax = %syntax.escape_debug(),
37 "emit_syntax"
38 );
39
40 // Whitespace-only content (trailing spaces, newlines) should be emitted
41 // as plain text, not wrapped in a hideable syntax span
42 let is_whitespace_only = syntax.trim().is_empty();
43
44 if is_whitespace_only {
45 // Check if we need to create a wrapper for standalone gap content.
46 let created_node = if self.current_node.id.is_none() {
47 let node_id = self.gen_node_id();
48 write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
49 self.begin_node(node_id);
50 true
51 } else {
52 false
53 };
54
55 // Only convert newlines to <br /> when this is standalone gap content
56 // (created_node = true). Inside paragraphs, keep newlines as-is.
57 if created_node {
58 // Gap content: the first newline is just the paragraph break (already
59 // visual from div structure), so emit only ZWSP. Additional newlines
60 // are actual blank lines, so emit <br /> + ZWSP for those.
61 let mut byte_offset = range.start;
62 let mut char_offset = char_start;
63 let mut newline_count = 0usize;
64 for ch in syntax.chars() {
65 let char_byte_len = ch.len_utf8();
66 if ch == '\n' {
67 newline_count += 1;
68 let utf16_len = if newline_count == 1 {
69 // First newline: just ZWSP (paragraph break is already visual).
70 self.write("\u{200B}")?;
71 1
72 } else {
73 // Additional newlines: literal \n + ZWSP.
74 // CSS white-space-collapse: break-spaces handles the visual break.
75 self.write("\n\u{200B}")?;
76 2
77 };
78 if let Some(ref node_id) = self.current_node.id {
79 let mapping = OffsetMapping {
80 byte_range: byte_offset..byte_offset + char_byte_len,
81 char_range: char_offset..char_offset + 1,
82 node_id: node_id.clone(),
83 char_offset_in_node: self.current_node.char_offset,
84 child_index: None,
85 utf16_len,
86 };
87 self.current_para.offset_maps.push(mapping);
88 self.current_node.char_offset += utf16_len;
89 }
90 } else {
91 escape_html(&mut self.writer, &ch.to_string())?;
92 if let Some(ref node_id) = self.current_node.id {
93 let mapping = OffsetMapping {
94 byte_range: byte_offset..byte_offset + char_byte_len,
95 char_range: char_offset..char_offset + 1,
96 node_id: node_id.clone(),
97 char_offset_in_node: self.current_node.char_offset,
98 child_index: None,
99 utf16_len: 1,
100 };
101 self.current_para.offset_maps.push(mapping);
102 self.current_node.char_offset += 1;
103 }
104 }
105 byte_offset += char_byte_len;
106 char_offset += 1;
107 }
108 } else {
109 // Inside a paragraph: emit whitespace as plain text.
110 escape_html(&mut self.writer, syntax)?;
111 self.record_mapping(range.clone(), char_start..char_end);
112 }
113
114 self.last_char_offset = char_end;
115 self.last_byte_offset = range.end;
116
117 if created_node {
118 self.write("</span>")?;
119 self.end_node();
120 }
121 } else {
122 // Real syntax - wrap in hideable span
123 let syntax_type = classify_syntax(syntax);
124 let class = match syntax_type {
125 SyntaxType::Inline => "md-syntax-inline",
126 SyntaxType::Block => "md-syntax-block",
127 };
128
129 // Generate unique ID for this syntax span
130 let syn_id = self.gen_syn_id();
131
132 // If we're outside any node, create a wrapper span for tracking
133 let created_node = if self.current_node.id.is_none() {
134 let node_id = self.gen_node_id();
135 write!(
136 &mut self.writer,
137 "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
138 node_id, class, syn_id, char_start, char_end
139 )?;
140 self.begin_node(node_id);
141 true
142 } else {
143 write!(
144 &mut self.writer,
145 "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
146 class, syn_id, char_start, char_end
147 )?;
148 false
149 };
150
151 escape_html(&mut self.writer, syntax)?;
152 self.write("</span>")?;
153
154 // Record syntax span info for visibility toggling
155 self.current_para.syntax_spans.push(SyntaxSpanInfo {
156 syn_id,
157 char_range: char_start..char_end,
158 syntax_type,
159 formatted_range: None,
160 });
161
162 // Record offset mapping for this syntax
163 self.record_mapping(range.clone(), char_start..char_end);
164 self.last_char_offset = char_end;
165 self.last_byte_offset = range.end;
166
167 // Close wrapper if we created one
168 if created_node {
169 self.write("</span>")?;
170 self.end_node();
171 }
172 }
173 }
174 }
175 Ok(())
176 }
177
178 /// Emit syntax span inside current node with full offset tracking.
179 ///
180 /// Use this for syntax markers that appear inside block elements (headings, lists,
181 /// blockquotes, code fences). Unlike `emit_syntax` which is for gaps and creates
182 /// wrapper nodes, this assumes we're already inside a tracked node.
183 ///
184 /// - Writes `<span class="md-syntax-{class}">{syntax}</span>`
185 /// - Records offset mapping (for cursor positioning)
186 /// - Updates both `last_char_offset` and `last_byte_offset`
187 pub(crate) fn emit_inner_syntax(
188 &mut self,
189 syntax: &str,
190 byte_start: usize,
191 syntax_type: SyntaxType,
192 ) -> Result<(), fmt::Error> {
193 if syntax.is_empty() {
194 return Ok(());
195 }
196
197 let char_start = self.last_char_offset;
198 let syntax_char_len = syntax.chars().count();
199 let char_end = char_start + syntax_char_len;
200 let byte_end = byte_start + syntax.len();
201
202 let class_str = match syntax_type {
203 SyntaxType::Inline => "md-syntax-inline",
204 SyntaxType::Block => "md-syntax-block",
205 };
206
207 // Generate unique ID for this syntax span
208 let syn_id = self.gen_syn_id();
209
210 write!(
211 &mut self.writer,
212 "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
213 class_str, syn_id, char_start, char_end
214 )?;
215 escape_html(&mut self.writer, syntax)?;
216 self.write("</span>")?;
217
218 // Record syntax span info for visibility toggling
219 self.current_para.syntax_spans.push(SyntaxSpanInfo {
220 syn_id,
221 char_range: char_start..char_end,
222 syntax_type,
223 formatted_range: None,
224 });
225
226 // Record offset mapping for cursor positioning
227 self.record_mapping(byte_start..byte_end, char_start..char_end);
228
229 self.last_char_offset = char_end;
230 self.last_byte_offset = byte_end;
231
232 Ok(())
233 }
234
235 /// Emit any gap between last position and next offset.
236 pub(crate) fn emit_gap_before(&mut self, next_offset: usize) -> Result<(), fmt::Error> {
237 // Skip gap emission if we're inside a table being rendered as markdown
238 if self.table.start_offset.is_some() && self.table.render_as_markdown {
239 return Ok(());
240 }
241
242 // Skip gap emission if we're buffering code block content
243 // The code block handler manages its own syntax emission
244 if self.code_block.is_active() {
245 return Ok(());
246 }
247
248 if next_offset > self.last_byte_offset {
249 self.emit_syntax(self.last_byte_offset..next_offset)?;
250 }
251 Ok(())
252 }
253}