1use markdown_it::parser::block::{BlockRule, BlockState};
2use markdown_it::parser::inline::{InlineRoot, InlineRule, InlineState};
3use markdown_it::{Node, NodeValue, Renderer};
4
5use crate::marko_ast::OpenOrClose;
6use crate::plugin::{get_line_raw, ErrorBlock};
7use crate::{marko, marko_ast};
8
9/// An opening Marko tag: <div>, <if=cond>, <for|item| of=items>, etc.
10#[derive(Debug)]
11pub struct MarkoOpen {
12 pub open: marko_ast::OpenOwned,
13}
14
15impl NodeValue for MarkoOpen {
16 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
17 fmt.text_raw(&self.open.src);
18 fmt.text_raw("\n");
19 }
20}
21
22/// A closing Marko tag: </div>, </>
23#[derive(Debug)]
24pub struct MarkoClose {
25 pub content: String,
26 pub tag_name: Option<String>, // None for </>
27 /// Length of the tag name (for error span highlighting), None for </>
28 pub tag_name_len: Option<usize>,
29}
30
31impl NodeValue for MarkoClose {
32 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
33 fmt.text_raw(&self.content);
34 fmt.text_raw("\n");
35 }
36}
37
38/// A closing Marko tag with same-line text: </div>text here
39/// The text is parsed as inline markdown (children) but not wrapped in <p>
40#[derive(Debug)]
41pub struct MarkoCloseWithText {
42 pub content: String,
43 pub tag_name: Option<String>, // None for </>
44 /// Length of the tag name (for error span highlighting), None for </>
45 pub tag_name_len: Option<usize>,
46}
47
48impl NodeValue for MarkoCloseWithText {
49 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
50 fmt.text_raw(&self.content);
51 fmt.text_raw("\n");
52 fmt.contents(&node.children);
53 fmt.text_raw("\n");
54 }
55}
56
57/// A self-closing tag: <input />, <Chart data=x />
58#[derive(Debug)]
59pub struct MarkoSelfClosing {
60 pub content: String,
61}
62
63impl NodeValue for MarkoSelfClosing {
64 fn render(&self, _node: &Node, fmt: &mut dyn Renderer) {
65 fmt.text_raw(&self.content);
66 fmt.text_raw("\n");
67 }
68}
69
70/// An opening Marko tag with same-line text: <div>text here
71/// The text is parsed as inline markdown (children) but not wrapped in <p>
72#[derive(Debug)]
73pub struct MarkoOpenWithText {
74 pub open: marko_ast::OpenOwned,
75}
76
77impl NodeValue for MarkoOpenWithText {
78 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
79 fmt.text_raw(&self.open.src);
80 fmt.text_raw("\n");
81 fmt.contents(&node.children);
82 fmt.text_raw("\n");
83 }
84}
85
86// ============================================================================
87// Inline tag markers and types
88// ============================================================================
89
90/// Marker for an inline open tag, to be matched by post-processor
91#[derive(Debug)]
92pub struct MarkoInlineOpenMarker {
93 pub content: String,
94 pub tag_name: String,
95 pub tag_name_len: usize,
96}
97
98impl NodeValue for MarkoInlineOpenMarker {
99 fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) {
100 // Should be replaced by post-processor before rendering
101 panic!("MarkoInlineOpenMarker should be processed before rendering");
102 }
103}
104
105/// Marker for an inline close tag, to be matched by post-processor
106#[derive(Debug)]
107pub struct MarkoInlineCloseMarker {
108 pub content: String,
109 pub tag_name: Option<String>, // None for </>
110 pub tag_name_len: Option<usize>,
111}
112
113impl NodeValue for MarkoInlineCloseMarker {
114 fn render(&self, _node: &Node, _fmt: &mut dyn Renderer) {
115 // Should be replaced by post-processor before rendering
116 panic!("MarkoInlineCloseMarker should be processed before rendering");
117 }
118}
119
120/// A matched inline Marko tag with content: <button>click me</button>
121#[derive(Debug)]
122pub struct MarkoInlineTag {
123 pub open_tag: String,
124 pub close_tag: String,
125 pub tag_name: String,
126}
127
128impl NodeValue for MarkoInlineTag {
129 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
130 fmt.text_raw(&self.open_tag);
131 fmt.contents(&node.children);
132 fmt.text_raw(&self.close_tag);
133 }
134}
135
136/// A complete block-level tag on one line: <tag>content</tag>
137/// The content is parsed as inline markdown
138#[derive(Debug)]
139pub struct MarkoBlockComplete {
140 pub open: marko_ast::OpenOwned,
141 pub close_tag: String,
142}
143
144impl NodeValue for MarkoBlockComplete {
145 fn render(&self, node: &Node, fmt: &mut dyn Renderer) {
146 fmt.text_raw(&self.open.src);
147 fmt.contents(&node.children);
148 fmt.text_raw(&self.close_tag);
149 fmt.text_raw("\n");
150 }
151}
152
153/// Find a matching close tag in content for the given tag name.
154/// Returns (content_before_close, close_tag_text) if found.
155///
156/// This function scans forward through the content, tracking nested tags.
157/// A close tag only matches the outer tag when nesting depth is zero.
158fn find_same_line_close<'a>(content: &'a str, tag_name: &str) -> Option<(&'a str, &'a str)> {
159 // Stack of open tag names for tracking nesting
160 let mut stack: Vec<&str> = Vec::new();
161 let mut pos = 0;
162
163 while pos < content.len() {
164 // Find next '<'
165 let Some(lt_pos) = content[pos..].find('<') else {
166 break;
167 };
168 let lt_pos = pos + lt_pos;
169
170 // Try to parse as a tag
171 match marko::parse_tag(&content[lt_pos..]) {
172 Ok(OpenOrClose::Open(open)) => {
173 if !open.self_closing {
174 stack.push(open.tag_name());
175 }
176 pos = lt_pos + (open.src.len());
177 }
178 Ok(OpenOrClose::Close(close)) => {
179 if stack.is_empty() {
180 if close.tag_name.map(|n| n == tag_name).unwrap_or(true) {
181 let content_before = &content[..lt_pos];
182 let close_tag = &content[lt_pos..lt_pos + close.length as usize];
183 return Some((content_before, close_tag));
184 }
185 pos = lt_pos + close.length as usize;
186 } else {
187 if close.tag_name.is_none() || stack.last() == close.tag_name.as_ref() {
188 stack.pop();
189 }
190 pos = lt_pos + close.length as usize;
191 }
192 }
193 Err(_) => {
194 // TODO: why no forward
195 pos = lt_pos + 1;
196 }
197 }
198 }
199
200 None
201}
202
203/// Parse Marko tags at block level
204pub struct Rule;
205
206impl BlockRule for Rule {
207 fn run(state: &mut BlockState) -> Option<(Node, usize)> {
208 let line = get_line_raw(state, state.line);
209 let trimmed = line.trim_start();
210 if !trimmed.starts_with("<") {
211 return None;
212 }
213
214 let unbounded_src = &state.src[state.line_offsets[state.line].first_nonspace..];
215
216 match marko::parse_tag(unbounded_src) {
217 Ok(OpenOrClose::Open(open)) => {
218 // Count how many lines this tag spans
219 let lines_consumed = 1 + open.src.chars().filter(|&c| c == '\n').count();
220
221 if open.self_closing {
222 // <input /> - self-closing tag
223 return Some((
224 Node::new(MarkoSelfClosing {
225 content: open.src.to_string(),
226 }),
227 lines_consumed,
228 ));
229 }
230
231 // Check if there's content on the same line after the tag
232 // We need to look at unbounded_src, not trimmed, since tag may span multiple lines
233 let rest_after_tag = &unbounded_src[open.src.len()..];
234 let rest_of_last_line = rest_after_tag.lines().next().unwrap_or("");
235 if !rest_of_last_line.trim().is_empty() {
236 let text = rest_of_last_line.trim();
237
238 // Check if the same-line content contains a matching close tag
239 if let Some((content, close_tag)) = find_same_line_close(text, open.tag_name())
240 {
241 // Complete tag on one line: <tag>content</tag>.
242 // If the previous line is non-empty prose (not another tag), this
243 // self-contained tag is inline content within a paragraph - let
244 // the paragraph rule handle it instead.
245 if state.line > 0 {
246 let prev_line = get_line_raw(state, state.line - 1).trim();
247 if !prev_line.is_empty() && !prev_line.starts_with('<') {
248 return None;
249 }
250 }
251
252 let content = content.trim();
253
254 // Calculate byte offset for source mapping
255 let text_start_in_line = open.src.len()
256 + (rest_of_last_line.len() - rest_of_last_line.trim_start().len());
257 let line_start = state.line_offsets[state.line].first_nonspace;
258 let text_start = line_start + text_start_in_line;
259
260 let mapping = vec![(0, text_start)];
261
262 let mut node = Node::new(MarkoBlockComplete {
263 open: open.to_owned(),
264 close_tag: close_tag.to_string(),
265 });
266
267 if !content.is_empty() {
268 node.children
269 .push(Node::new(InlineRoot::new(content.to_string(), mapping)));
270 }
271
272 return Some((node, lines_consumed));
273 }
274
275 // No close tag - just content after open tag
276 // Parse the text as inline markdown (not wrapped in paragraph)
277 let text_start_in_line = open.src.len()
278 + (rest_of_last_line.len() - rest_of_last_line.trim_start().len());
279 let line_start = state.line_offsets[state.line].first_nonspace;
280 let text_start = line_start + text_start_in_line;
281
282 let mapping = vec![(0, text_start)];
283
284 let mut node = Node::new(MarkoOpenWithText {
285 open: open.to_owned(),
286 });
287 node.children
288 .push(Node::new(InlineRoot::new(text.to_string(), mapping)));
289
290 return Some((node, lines_consumed));
291 }
292
293 // Check if next line after the tag has content (non-blank) that isn't another tag
294 let next_line_idx = state.line + lines_consumed;
295 if next_line_idx < state.line_max {
296 let next_line = get_line_raw(state, next_line_idx);
297 let next_trimmed = next_line.trim();
298 if !next_trimmed.is_empty() && !next_trimmed.starts_with("<") {
299 // Content immediately follows without blank line
300 // Parse as inline markdown (not wrapped in paragraph)
301 let text = next_trimmed;
302
303 // Calculate byte offset of next line's text for source mapping
304 let next_line_start = state.line_offsets[next_line_idx].first_nonspace;
305
306 let mapping = vec![(0, next_line_start)];
307
308 let mut node = Node::new(MarkoOpenWithText {
309 open: open.to_owned(),
310 });
311 node.children
312 .push(Node::new(InlineRoot::new(text.to_string(), mapping)));
313
314 // Consume the tag lines + the content line
315 return Some((node, lines_consumed + 1));
316 }
317 }
318
319 // Clean case: open tag on its own line(s), followed by blank or another tag
320 Some((
321 Node::new(MarkoOpen {
322 open: open.to_owned(),
323 }),
324 lines_consumed,
325 ))
326 }
327 Ok(OpenOrClose::Close(close)) => {
328 // Close tags should be single line, but check anyway
329 let lines_consumed = 1 + close.length.saturating_sub(1).min(
330 unbounded_src[..close.length as usize]
331 .chars()
332 .filter(|&c| c == '\n')
333 .count() as u32,
334 );
335
336 // Check if there's content on the same line after the close tag
337 let close_text = &unbounded_src[..close.length as usize];
338 let rest_after_tag = &unbounded_src[close.length as usize..];
339 let rest_of_line = rest_after_tag.lines().next().unwrap_or("");
340 if !rest_of_line.trim().is_empty() {
341 let text = rest_of_line.trim();
342
343 // Calculate byte offset of the text for source mapping
344 let text_start_in_line = close.length as usize
345 + (rest_of_line.len() - rest_of_line.trim_start().len());
346 let line_start = state.line_offsets[state.line].first_nonspace;
347 let text_start = line_start + text_start_in_line;
348
349 let mapping = vec![(0, text_start)];
350
351 let mut node = Node::new(MarkoCloseWithText {
352 content: close_text.to_string(),
353 tag_name: close.tag_name.map(|s| s.to_string()),
354 tag_name_len: close.tag_name.map(|s| s.len()),
355 });
356 node.children
357 .push(Node::new(InlineRoot::new(text.to_string(), mapping)));
358
359 return Some((node, lines_consumed as usize));
360 }
361
362 Some((
363 Node::new(MarkoClose {
364 content: close_text.to_string(),
365 tag_name: close.tag_name.map(|s| s.to_string()),
366 tag_name_len: close.tag_name.map(|s| s.len()),
367 }),
368 lines_consumed as usize,
369 ))
370 }
371 Err(err) => Some((Node::new(ErrorBlock { errors: vec![err] }), 1)),
372 }
373 }
374}
375
376impl InlineRule for Rule {
377 const MARKER: char = '<';
378
379 fn run(state: &mut InlineState) -> Option<(Node, usize)> {
380 let input = &state.src[state.pos..state.pos_max];
381 if !input.starts_with("<") {
382 return None;
383 }
384
385 match marko::parse_tag(input) {
386 Ok(OpenOrClose::Open(open)) => {
387 if open.self_closing {
388 return Some((
389 Node::new(MarkoSelfClosing {
390 content: open.src.to_string(),
391 }),
392 open.src.len(),
393 ));
394 }
395
396 Some((
397 Node::new(MarkoInlineOpenMarker {
398 content: open.src.to_string(),
399 tag_name: open.tag_name().to_owned(),
400 tag_name_len: open.tag_name().len(),
401 }),
402 open.src.len(),
403 ))
404 }
405 Ok(OpenOrClose::Close(close)) => {
406 let close_text = &input[..close.length as usize];
407 Some((
408 Node::new(MarkoInlineCloseMarker {
409 content: close_text.to_string(),
410 tag_name: close.tag_name.map(|s| s.to_string()),
411 tag_name_len: close.tag_name.map(|s| s.len()),
412 }),
413 close.length as usize,
414 ))
415 }
416 // TODO: why this no reported
417 Err(_) => None,
418 }
419 }
420}
421
422#[cfg(test)]
423mod tests {
424 use super::find_same_line_close;
425 use crate::marko;
426
427 #[test]
428 fn test_find_same_line_close_with_nested_tags() {
429 // Should find </> after the nested <code></code>
430 let content = "<code>aaa</code> mmmm</>";
431 let result = find_same_line_close(content, "h3");
432 assert!(result.is_some(), "Should find closing tag");
433 let (before, close) = result.unwrap();
434 assert_eq!(before, "<code>aaa</code> mmmm");
435 assert_eq!(close, "</>");
436 }
437
438 #[test]
439 fn test_block_tag_with_inline_content() {
440 // the string
441 // 'bbbbbbb'><code>aaa</code> mmmm</>
442 //
443 // 'string' > <T>id < /regexp/ >
444 let src = "<h3 id='bbbbbbb'><code>aaa</code> mmmm</>";
445 let open = marko::parse_open(src).unwrap();
446 assert_eq!(open.tag_name(), "h3");
447 assert_eq!(open.src, "<h3 id='bbbbbbb'>");
448
449 // The rest after the tag
450 let rest = &src[open.src.len()..];
451 assert_eq!(rest, "<code>aaa</code> mmmm</>");
452
453 // find_same_line_close should find the </> at the end
454 let result = find_same_line_close(rest, "h3");
455 assert!(result.is_some(), "Should find closing tag for h3");
456 let (content, close) = result.unwrap();
457 assert_eq!(content, "<code>aaa</code> mmmm");
458 assert_eq!(close, "</>");
459 }
460
461 #[test]
462 fn test_nested_greater_than() {
463 let src = "<h3 id='bbbbbbb'><code>aaa</code> mmmm</>";
464 let open = marko::parse_open(src).unwrap();
465 assert_eq!(open.tag_name(), "h3");
466 assert_eq!(open.src, "<h3 id='bbbbbbb'>");
467
468 // The rest after the tag
469 let rest = &src[open.src.len()..];
470 assert_eq!(rest, "<code>aaa</code> mmmm</>");
471
472 // find_same_line_close should find the </> at the end
473 let result = find_same_line_close(rest, "h3");
474 assert!(result.is_some(), "Should find closing tag for h3");
475 let (content, close) = result.unwrap();
476 assert_eq!(content, "<code>aaa</code> mmmm");
477 assert_eq!(close, "</>");
478 }
479}