| author | |
| committer | |
| log | e8d9aa0ccc17ba1add4757022b7121d0f731858c |
| tree | 5fed1d6c5db75a2ce6b0bfebee48e29412a5f4e0 |
| parent | d25d757a902f98e69e9178395f7857907d37d030 |
| signature |
3 files changed, 128 insertions(+), 4 deletions(-)
src/component_transforms.rs+14-1| ... | @@ -6,7 +6,9 @@ | ... | @@ -6,7 +6,9 @@ |
| 6 | use markdown_it::{Node, NodeValue, Renderer}; | 6 | use markdown_it::{Node, NodeValue, Renderer}; |
| 7 | 7 | ||
| 8 | use crate::marko_ast::OpenOwned; | 8 | use crate::marko_ast::OpenOwned; |
| 9 | use crate::plugin::tags::{MarkoBlockComplete, MarkoClose, MarkoOpen, MarkoOpenWithText}; | 9 | use crate::plugin::tags::{ |
| 10 | MarkoBlockComplete, MarkoClose, MarkoCloseWithText, MarkoOpen, MarkoOpenWithText, | ||
| 11 | }; | ||
| 10 | use crate::ComponentImports; | 12 | use crate::ComponentImports; |
| 11 | 13 | ||
| 12 | /// Component name suffix to avoid collisions | 14 | /// Component name suffix to avoid collisions |
| ... | @@ -446,6 +448,17 @@ fn transform_heading(node: &mut Node) { | ... | @@ -446,6 +448,17 @@ fn transform_heading(node: &mut Node) { |
| 446 | } | 448 | } |
| 447 | } | 449 | } |
| 448 | } | 450 | } |
| 451 | |||
| 452 | if let Some(marko_close) = node.cast_mut::<MarkoCloseWithText>() { | ||
| 453 | if let Some(tag_name) = &marko_close.tag_name { | ||
| 454 | if parse_heading_level(tag_name).is_some() { | ||
| 455 | // Replace with generic close tag | ||
| 456 | marko_close.content = "</>".to_string(); | ||
| 457 | marko_close.tag_name = None; | ||
| 458 | marko_close.tag_name_len = None; | ||
| 459 | } | ||
| 460 | } | ||
| 461 | } | ||
| 449 | } | 462 | } |
| 450 | 463 | ||
| 451 | /// Parse h1-h6 tag names and return the level | 464 | /// Parse h1-h6 tag names and return the level |
src/lib.rs+76-1| ... | @@ -8,7 +8,7 @@ pub mod wasm; | ... | @@ -8,7 +8,7 @@ pub mod wasm; |
| 8 | 8 | ||
| 9 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; | 9 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; |
| 10 | use oxc_span::Span; | 10 | use oxc_span::Span; |
| 11 | use plugin::tags::{MarkoClose, MarkoOpen}; | 11 | use plugin::tags::{MarkoClose, MarkoCloseWithText, MarkoOpen}; |
| 12 | use serde::{Deserialize, Serialize}; | 12 | use serde::{Deserialize, Serialize}; |
| 13 | use std::borrow::Cow; | 13 | use std::borrow::Cow; |
| 14 | use wasm_bindgen::prelude::wasm_bindgen; | 14 | use wasm_bindgen::prelude::wasm_bindgen; |
| ... | @@ -312,6 +312,7 @@ fn has_marko_features(node: &markdown_it::Node) -> bool { | ... | @@ -312,6 +312,7 @@ fn has_marko_features(node: &markdown_it::Node) -> bool { |
| 312 | || node.cast::<plugin::StatementBlock>().is_some() | 312 | || node.cast::<plugin::StatementBlock>().is_some() |
| 313 | || node.cast::<MarkoOpen>().is_some() | 313 | || node.cast::<MarkoOpen>().is_some() |
| 314 | || node.cast::<MarkoClose>().is_some() | 314 | || node.cast::<MarkoClose>().is_some() |
| 315 | || node.cast::<MarkoCloseWithText>().is_some() | ||
| 315 | || node.cast::<plugin::tags::MarkoSelfClosing>().is_some() | 316 | || node.cast::<plugin::tags::MarkoSelfClosing>().is_some() |
| 316 | || node.cast::<plugin::tags::MarkoOpenWithText>().is_some() | 317 | || node.cast::<plugin::tags::MarkoOpenWithText>().is_some() |
| 317 | || node.cast::<plugin::tags::MarkoInlineTag>().is_some() | 318 | || node.cast::<plugin::tags::MarkoInlineTag>().is_some() |
| ... | @@ -395,6 +396,48 @@ fn validate_marko_tags( | ... | @@ -395,6 +396,48 @@ fn validate_marko_tags( |
| 395 | ); | 396 | ); |
| 396 | } | 397 | } |
| 397 | } | 398 | } |
| 399 | } else if let Some(close) = node.cast::<MarkoCloseWithText>() { | ||
| 400 | let (close_start, _) = node.srcmap.unwrap().get_byte_offsets(); | ||
| 401 | let close_start = close_start as u32 + offset; | ||
| 402 | // Close tag name starts after '</' (offset +2) | ||
| 403 | let close_span = close | ||
| 404 | .tag_name | ||
| 405 | .as_ref() | ||
| 406 | .map(|name| Span::new(close_start + 2, close_start + 2 + name.len() as u32)); | ||
| 407 | |||
| 408 | match (close.tag_name.as_ref(), stack.pop()) { | ||
| 409 | (Some(name), Some((top_name, _))) if name == &top_name => { | ||
| 410 | // valid | ||
| 411 | } | ||
| 412 | (None, Some(_)) => { | ||
| 413 | // </> closes anything - valid | ||
| 414 | } | ||
| 415 | (Some(name), Some((top_name, top_span))) => { | ||
| 416 | errors.push( | ||
| 417 | OxcDiagnostic::error(format!( | ||
| 418 | "Mismatched closing tag: expected </{top_name}>, found </{name}>" | ||
| 419 | )) | ||
| 420 | .with_labels(vec![ | ||
| 421 | top_span.label("opened here"), | ||
| 422 | close_span.unwrap().label("closed here"), | ||
| 423 | ]), | ||
| 424 | ); | ||
| 425 | } | ||
| 426 | (Some(name), None) => { | ||
| 427 | errors.push( | ||
| 428 | OxcDiagnostic::error(format!( | ||
| 429 | "Closing tag </{name}> without matching open" | ||
| 430 | )) | ||
| 431 | .with_label(close_span.unwrap()), | ||
| 432 | ); | ||
| 433 | } | ||
| 434 | (None, None) => { | ||
| 435 | errors.push( | ||
| 436 | OxcDiagnostic::error("Closing tag </> without matching open") | ||
| 437 | .with_label(Span::new(close_start, close_start + 3)), | ||
| 438 | ); | ||
| 439 | } | ||
| 440 | } | ||
| 398 | } | 441 | } |
| 399 | 442 | ||
| 400 | // Recurse into children | 443 | // Recurse into children |
| ... | @@ -1112,4 +1155,36 @@ mod tests { | ... | @@ -1112,4 +1155,36 @@ mod tests { |
| 1112 | "explicit link import should be present" | 1155 | "explicit link import should be present" |
| 1113 | ); | 1156 | ); |
| 1114 | } | 1157 | } |
| 1158 | |||
| 1159 | #[test] | ||
| 1160 | fn close_tag_with_same_line_text() { | ||
| 1161 | let out = run("<div>\n</div> trailing text\n"); | ||
| 1162 | assert!(out.contains("</div>"), "close tag missing"); | ||
| 1163 | assert!( | ||
| 1164 | out.contains("trailing text"), | ||
| 1165 | "same-line text after close tag missing" | ||
| 1166 | ); | ||
| 1167 | } | ||
| 1168 | |||
| 1169 | #[test] | ||
| 1170 | fn close_tag_with_same_line_text_validated() { | ||
| 1171 | let result = transform( | ||
| 1172 | "<section>\ncontent\n</section> after", | ||
| 1173 | None, | ||
| 1174 | None, | ||
| 1175 | None, | ||
| 1176 | None, | ||
| 1177 | false, | ||
| 1178 | None, | ||
| 1179 | ); | ||
| 1180 | assert!( | ||
| 1181 | result.is_ok(), | ||
| 1182 | "should not error on close tag with trailing text" | ||
| 1183 | ); | ||
| 1184 | let out = result.unwrap().text; | ||
| 1185 | assert!( | ||
| 1186 | out.contains("after"), | ||
| 1187 | "trailing text should appear in output" | ||
| 1188 | ); | ||
| 1189 | } | ||
| 1115 | } | 1190 | } |
src/plugin/tags.rs+38-2| ... | @@ -35,6 +35,25 @@ impl NodeValue for MarkoClose { | ... | @@ -35,6 +35,25 @@ impl NodeValue for MarkoClose { |
| 35 | } | 35 | } |
| 36 | } | 36 | } |
| 37 | 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)] | ||
| 41 | pub 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 | |||
| 48 | impl 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 | |||
| 38 | /// A self-closing tag: <input />, <Chart data=x /> | 57 | /// A self-closing tag: <input />, <Chart data=x /> |
| 39 | #[derive(Debug)] | 58 | #[derive(Debug)] |
| 40 | pub struct MarkoSelfClosing { | 59 | pub struct MarkoSelfClosing { |
| ... | @@ -309,8 +328,25 @@ impl BlockRule for Rule { | ... | @@ -309,8 +328,25 @@ impl BlockRule for Rule { |
| 309 | let rest_after_tag = &unbounded_src[close.length as usize..]; | 328 | let rest_after_tag = &unbounded_src[close.length as usize..]; |
| 310 | let rest_of_line = rest_after_tag.lines().next().unwrap_or(""); | 329 | let rest_of_line = rest_after_tag.lines().next().unwrap_or(""); |
| 311 | if !rest_of_line.trim().is_empty() { | 330 | if !rest_of_line.trim().is_empty() { |
| 312 | // TODO: content after close tag on same line | 331 | let text = rest_of_line.trim(); |
| 313 | todo!("content after close tag: {}", rest_of_line.trim()); | 332 | |
| 333 | // Calculate byte offset of the text for source mapping | ||
| 334 | let text_start_in_line = close.length as usize | ||
| 335 | + (rest_of_line.len() - rest_of_line.trim_start().len()); | ||
| 336 | let line_start = state.line_offsets[state.line].first_nonspace; | ||
| 337 | let text_start = line_start + text_start_in_line; | ||
| 338 | |||
| 339 | let mapping = vec![(0, text_start)]; | ||
| 340 | |||
| 341 | let mut node = Node::new(MarkoCloseWithText { | ||
| 342 | content: close_text.to_string(), | ||
| 343 | tag_name: close.tag_name.map(|s| s.to_string()), | ||
| 344 | tag_name_len: close.tag_name.map(|s| s.len()), | ||
| 345 | }); | ||
| 346 | node.children | ||
| 347 | .push(Node::new(InlineRoot::new(text.to_string(), mapping))); | ||
| 348 | |||
| 349 | return Some((node, lines_consumed as usize)); | ||
| 314 | } | 350 | } |
| 315 | 351 | ||
| 316 | Some(( | 352 | Some(( |