| 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 | 6 | use markdown_it::{Node, NodeValue, Renderer}; |
| 7 | 7 | |
| 8 | 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 | 12 | use crate::ComponentImports; |
| 11 | 13 | |
| 12 | 14 | /// Component name suffix to avoid collisions |
| ... | ... | @@ -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 | 464 | /// Parse h1-h6 tag names and return the level |
src/lib.rs+76-1| ... | ... | @@ -8,7 +8,7 @@ pub mod wasm; |
| 8 | 8 | |
| 9 | 9 | use oxc_diagnostics::{LabeledSpan, OxcDiagnostic}; |
| 10 | 10 | use oxc_span::Span; |
| 11 | use plugin::tags::{MarkoClose, MarkoOpen}; | |
| 11 | use plugin::tags::{MarkoClose, MarkoCloseWithText, MarkoOpen}; | |
| 12 | 12 | use serde::{Deserialize, Serialize}; |
| 13 | 13 | use std::borrow::Cow; |
| 14 | 14 | use wasm_bindgen::prelude::wasm_bindgen; |
| ... | ... | @@ -312,6 +312,7 @@ fn has_marko_features(node: &markdown_it::Node) -> bool { |
| 312 | 312 | || node.cast::<plugin::StatementBlock>().is_some() |
| 313 | 313 | || node.cast::<MarkoOpen>().is_some() |
| 314 | 314 | || node.cast::<MarkoClose>().is_some() |
| 315 | || node.cast::<MarkoCloseWithText>().is_some() | |
| 315 | 316 | || node.cast::<plugin::tags::MarkoSelfClosing>().is_some() |
| 316 | 317 | || node.cast::<plugin::tags::MarkoOpenWithText>().is_some() |
| 317 | 318 | || node.cast::<plugin::tags::MarkoInlineTag>().is_some() |
| ... | ... | @@ -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 | 443 | // Recurse into children |
| ... | ... | @@ -1112,4 +1155,36 @@ mod tests { |
| 1112 | 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 | 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 | 57 | /// A self-closing tag: <input />, <Chart data=x /> |
| 39 | 58 | #[derive(Debug)] |
| 40 | 59 | pub struct MarkoSelfClosing { |
| ... | ... | @@ -309,8 +328,25 @@ impl BlockRule for Rule { |
| 309 | 328 | let rest_after_tag = &unbounded_src[close.length as usize..]; |
| 310 | 329 | let rest_of_line = rest_after_tag.lines().next().unwrap_or(""); |
| 311 | 330 | if !rest_of_line.trim().is_empty() { |
| 312 | // TODO: content after close tag on same line | |
| 313 | todo!("content after close tag: {}", rest_of_line.trim()); | |
| 331 | let text = 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 | 352 | Some(( |