···426}
427428// =============================================================================
429+// Syntax Span Edge Case Tests
430+// =============================================================================
431+432+#[test]
433+fn test_invalid_heading_no_space() {
434+ // "#text" without space is NOT a valid heading - should be plain text
435+ // The '#' should NOT be wrapped in a syntax span
436+ let result = render_test("#text");
437+438+ // Should be a single paragraph with plain text
439+ assert_eq!(result.len(), 1, "Should have 1 paragraph");
440+441+ // HTML should NOT contain md-syntax-block for the #
442+ assert!(
443+ !result[0].html.contains("md-syntax-block"),
444+ "Invalid heading '#text' should NOT have block syntax span. HTML: {}",
445+ result[0].html
446+ );
447+448+ // The # should be visible as regular text content
449+ assert!(
450+ result[0].html.contains("#text") || result[0].html.contains("#text"),
451+ "The '#text' should appear as regular text. HTML: {}",
452+ result[0].html
453+ );
454+}
455+456+#[test]
457+fn test_valid_heading_with_space() {
458+ // "# text" WITH space IS a valid heading
459+ let result = render_test("# Heading");
460+461+ // Should have heading syntax span
462+ assert!(
463+ result[0].html.contains("md-syntax-block"),
464+ "Valid heading should have block syntax span. HTML: {}",
465+ result[0].html
466+ );
467+468+ // Should have <h1> tag
469+ assert!(
470+ result[0].html.contains("<h1"),
471+ "Valid heading should render as h1. HTML: {}",
472+ result[0].html
473+ );
474+}
475+476+#[test]
477+fn test_hash_in_middle_of_text() {
478+ // "#" in middle of text should not be treated as heading syntax
479+ let result = render_test("Some #hashtag here");
480+481+ assert!(
482+ !result[0].html.contains("md-syntax-block"),
483+ "# in middle of text should NOT be block syntax. HTML: {}",
484+ result[0].html
485+ );
486+}
487+488+#[test]
489+fn test_unclosed_bold() {
490+ // "**text" without closing ** should be plain text, not bold
491+ let result = render_test("**unclosed bold");
492+493+ // Should NOT have <strong> tag
494+ assert!(
495+ !result[0].html.contains("<strong>"),
496+ "Unclosed ** should NOT render as bold. HTML: {}",
497+ result[0].html
498+ );
499+}
500+501+#[test]
502+fn test_unclosed_italic() {
503+ // "*text" without closing * should be plain text, not italic
504+ let result = render_test("*unclosed italic");
505+506+ // Should NOT have <em> tag
507+ assert!(
508+ !result[0].html.contains("<em>"),
509+ "Unclosed * should NOT render as italic. HTML: {}",
510+ result[0].html
511+ );
512+}
513+514+#[test]
515+fn test_asterisk_not_emphasis() {
516+ // Single * surrounded by spaces is not emphasis
517+ let result = render_test("5 * 3 = 15");
518+519+ // Should NOT have <em> tag
520+ assert!(
521+ !result[0].html.contains("<em>"),
522+ "Math expression with * should NOT be italic. HTML: {}",
523+ result[0].html
524+ );
525+}
526+527+#[test]
528+fn test_list_marker_needs_space() {
529+ // "-text" without space is NOT a list item
530+ let result = render_test("-not-a-list");
531+532+ // Should NOT have <li> or <ul> tags
533+ assert!(
534+ !result[0].html.contains("<li>") && !result[0].html.contains("<ul>"),
535+ "'-text' without space should NOT be a list. HTML: {}",
536+ result[0].html
537+ );
538+}
539+540+#[test]
541+fn test_valid_list_with_space() {
542+ // "- text" WITH space IS a valid list item
543+ let result = render_test("- List item");
544+545+ // Should have list markup
546+ assert!(
547+ result[0].html.contains("<li>") || result[0].html.contains("<ul>"),
548+ "Valid list should have list markup. HTML: {}",
549+ result[0].html
550+ );
551+552+ // Should have block syntax span for the marker
553+ assert!(
554+ result[0].html.contains("md-syntax-block"),
555+ "List marker should have block syntax span. HTML: {}",
556+ result[0].html
557+ );
558+}
559+560+#[test]
561+fn test_number_dot_needs_space() {
562+ // "1.text" without space is NOT an ordered list
563+ let result = render_test("1.not-a-list");
564+565+ // Should NOT have <ol> tag
566+ assert!(
567+ !result[0].html.contains("<ol>"),
568+ "'1.text' without space should NOT be ordered list. HTML: {}",
569+ result[0].html
570+ );
571+}
572+573+#[test]
574+fn test_hash_with_zero_width_char() {
575+ // "#\u{200B}text" - zero-width space after # should NOT make it a valid heading
576+ let result = render_test("#\u{200B}text");
577+578+ // Debug: print what we got
579+ eprintln!("HTML for '#\\u{{200B}}text': {}", result[0].html);
580+581+ // Should NOT be a heading - zero-width space is not a real space
582+ assert!(
583+ !result[0].html.contains("<h1"),
584+ "# followed by zero-width space should NOT be h1. HTML: {}",
585+ result[0].html
586+ );
587+}
588+589+#[test]
590+fn test_hash_with_zwj() {
591+ // Test with zero-width joiner
592+ let result = render_test("#\u{200C}text");
593+594+ eprintln!("HTML for '#\\u{{200C}}text': {}", result[0].html);
595+596+ assert!(
597+ !result[0].html.contains("<h1"),
598+ "# followed by ZWNJ should NOT be h1. HTML: {}",
599+ result[0].html
600+ );
601+}
602+603+#[test]
604+fn test_hash_space_then_zero_width() {
605+ // "# \u{200B}" - valid heading marker, but content is just zero-width
606+ let result = render_test("# \u{200B}");
607+608+ eprintln!("HTML for '# \\u{{200B}}': {}", result[0].html);
609+ eprintln!("Syntax spans: {:?}", result[0].offset_map);
610+611+ // This IS a valid heading (has space after #), even if content is "invisible"
612+ // The question is: should we hide the # syntax in this case?
613+}
614+615+#[test]
616+fn test_hash_alone() {
617+ // Just "#" at EOL IS a valid empty heading (standard CommonMark behavior)
618+ let result = render_test("#");
619+ eprintln!("HTML for '#': {}", result[0].html);
620+621+ // This IS a heading - empty headings are valid
622+ assert!(
623+ result[0].html.contains("<h1"),
624+ "'#' alone IS a valid empty h1. HTML: {}",
625+ result[0].html
626+ );
627+}
628+629+#[test]
630+fn test_heading_to_non_heading_transition() {
631+ // Simulates typing: start with "#" (heading), then add "t" to make "#t" (not heading)
632+ // This tests that the syntax spans are correctly updated on content change.
633+ use loro::LoroDoc;
634+ use super::render::render_paragraphs_incremental;
635+636+ let doc = LoroDoc::new();
637+ let text = doc.get_text("content");
638+639+ // Initial state: "#" is a valid empty heading
640+ text.insert(0, "#").unwrap();
641+ let (paras1, cache1) = render_paragraphs_incremental(&text, None, None);
642+643+ eprintln!("State 1 ('#'): {}", paras1[0].html);
644+ assert!(paras1[0].html.contains("<h1"), "# alone should be heading");
645+ assert!(
646+ paras1[0].html.contains("md-syntax-block"),
647+ "# should have syntax span"
648+ );
649+650+ // Transition: add "t" to make "#t" - no longer a heading
651+ text.insert(1, "t").unwrap();
652+ let (paras2, _cache2) = render_paragraphs_incremental(&text, Some(&cache1), None);
653+654+ eprintln!("State 2 ('#t'): {}", paras2[0].html);
655+ assert!(
656+ !paras2[0].html.contains("<h1"),
657+ "#t should NOT be heading. HTML: {}",
658+ paras2[0].html
659+ );
660+ assert!(
661+ !paras2[0].html.contains("md-syntax-block"),
662+ "#t should NOT have block syntax span. HTML: {}",
663+ paras2[0].html
664+ );
665+}
666+667+#[test]
668+fn test_hash_space_alone() {
669+ // "# " (hash + space, no content) - IS this a heading?
670+ let result = render_test("# ");
671+ eprintln!("HTML for '# ': {}", result[0].html);
672+673+ // Document actual behavior - this determines if empty headings are valid
674+}
675+676+#[test]
677+fn test_empty_blockquote() {
678+ // Just ">" alone - empty blockquote
679+ // BUG: Currently produces 0 paragraphs, making the > invisible!
680+ let result = render_test(">");
681+ eprintln!("Paragraphs for '>': {:?}", result.len());
682+ for (i, p) in result.iter().enumerate() {
683+ eprintln!(" Para {}: html={}, char_range={:?}", i, p.html, p.char_range);
684+ }
685+686+ // Empty blockquote should still produce at least one paragraph
687+ // containing the > syntax so it can be rendered and edited
688+ assert!(
689+ !result.is_empty(),
690+ "Empty blockquote should produce at least one paragraph, got 0"
691+ );
692+}
693+694+#[test]
695+fn test_blockquote_needs_space_or_newline() {
696+ // ">text" directly attached might not be a blockquote depending on parser
697+ // This test documents expected behavior
698+ let result = render_test(">quote");
699+700+ // Whether this is a blockquote depends on the parser - document actual behavior
701+ insta::assert_yaml_snapshot!(result, @r#"
702+ - byte_range:
703+ - 6
704+ - 6
705+ char_range:
706+ - 0
707+ - 6
708+ html: "<blockquote>\n<p id=\"n0\"><span class=\"md-syntax-block\" data-syn-id=\"s0\" data-char-start=\"0\" data-char-end=\"1\">></span>quote</p>\n</blockquote>\n"
709+ offset_map:
710+ - byte_range:
711+ - 7
712+ - 7
713+ char_range:
714+ - 0
715+ - 0
716+ node_id: n0
717+ char_offset_in_node: 0
718+ child_index: 0
719+ utf16_len: 0
720+ - byte_range:
721+ - 6
722+ - 7
723+ char_range:
724+ - 0
725+ - 1
726+ node_id: n0
727+ char_offset_in_node: 0
728+ child_index: ~
729+ utf16_len: 1
730+ - byte_range:
731+ - 7
732+ - 12
733+ char_range:
734+ - 1
735+ - 6
736+ node_id: n0
737+ char_offset_in_node: 1
738+ child_index: ~
739+ utf16_len: 5
740+ source_hash: 6279293067953035109
741+ "#);
742+}
743+744+// =============================================================================
745// Char Range Coverage Tests
746// =============================================================================
747
+136-61
crates/weaver-app/src/components/editor/writer.rs
···359 {
360 opening_span.formatted_range = Some(formatted_range.clone());
361 } else {
362- tracing::warn!("[FINALIZE_PAIRED] Could not find opening span {}", opening_syn_id);
000363 }
364365 // Update the closing span's formatted_range (the most recent one)
···397 return Ok(());
398 }
399400- let syntax_type = classify_syntax(syntax);
401- let class = match syntax_type {
402- SyntaxType::Inline => "md-syntax-inline",
403- SyntaxType::Block => "md-syntax-block",
404- };
00000000000405406- // Generate unique ID for this syntax span
407- let syn_id = self.gen_syn_id();
00408409- // If we're outside any node, create a wrapper span for tracking
410- let created_node = if self.current_node_id.is_none() {
411- let node_id = self.gen_node_id();
412- write!(
413- &mut self.writer,
414- "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
415- node_id, class, syn_id, char_start, char_end
416- )?;
417- self.begin_node(node_id);
418- true
419 } else {
420- write!(
421- &mut self.writer,
422- "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
423- class, syn_id, char_start, char_end
424- )?;
425- false
426- };
000000000000000000000427428- escape_html(&mut self.writer, syntax)?;
429- self.write("</span>")?;
430431- // Record syntax span info for visibility toggling
432- self.syntax_spans.push(SyntaxSpanInfo {
433- syn_id,
434- char_range: char_start..char_end,
435- syntax_type,
436- formatted_range: None,
437- });
438439- // Record offset mapping for this syntax
440- self.record_mapping(range.clone(), char_start..char_end);
441- self.last_char_offset = char_end;
442- self.last_byte_offset = range.end; // Mark bytes as processed
443444- // Close wrapper if we created one
445- if created_node {
446- self.write("</span>")?;
447- self.end_node();
0448 }
449 }
450 }
···585586 // Only add checkpoint if we've advanced
587 if char_range.end > last_checkpoint.0 {
588- self.utf16_checkpoints.push((char_range.end, new_utf16_offset));
0589 }
590591 let mapping = OffsetMapping {
···675676 write!(
677 &mut self.writer,
678- "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
679 syn_id, char_start, char_end
680 )?;
681 escape_html(&mut self.writer, trailing)?;
682 self.write("</span>")?;
683684 // Record syntax span info
685- self.syntax_spans.push(SyntaxSpanInfo {
686- syn_id,
687- char_range: char_start..char_end,
688- syntax_type: SyntaxType::Inline,
689- formatted_range: None,
690- });
691692 // Record mapping if we have a node
693 if let Some(ref node_id) = self.current_node_id {
···984 let syn_id = self.gen_syn_id();
985 write!(
986 &mut self.writer,
987- "<span class=\"md-syntax-inline\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
988 syn_id, char_start, char_end
989 )?;
990 escape_html(&mut self.writer, spaces)?;
991 self.write("</span>")?;
992993 // Record syntax span info
994- self.syntax_spans.push(SyntaxSpanInfo {
995- syn_id,
996- char_range: char_start..char_end,
997- syntax_type: SyntaxType::Inline,
998- formatted_range: None,
999- });
10001001 // Count this span as a child
1002 self.current_node_child_count += 1;
···1013 self.current_node_child_count += 1;
10141015 // After <br>, emit plain zero-width space for cursor positioning
1016- self.write("\u{200B}")?;
010171018 // Count the zero-width space text node as a child
1019 self.current_node_child_count += 1;
···1228 // Record paragraph start for boundary tracking
1229 // BUT skip if inside a list - list owns the paragraph boundary
1230 if self.list_depth == 0 {
1231- self.current_paragraph_start = Some((self.last_byte_offset, self.last_char_offset));
01232 }
12331234 let node_id = self.gen_node_id();
···1268 }
1269 } else {
1270 // Just > and maybe a space
1271- (gt_pos + 2).min(raw_text.len())
1272 };
12731274 let syntax = &raw_text[gt_pos..syntax_end];
···1890 Ok(())
1891 }
1892 }
1893- TagEnd::BlockQuote(_) => self.write("</blockquote>\n"),
00000000000000000000000000000000000000001894 TagEnd::CodeBlock => {
1895 use std::sync::LazyLock;
1896 use syntect::parsing::SyntaxSet;
···359 {
360 opening_span.formatted_range = Some(formatted_range.clone());
361 } else {
362+ tracing::warn!(
363+ "[FINALIZE_PAIRED] Could not find opening span {}",
364+ opening_syn_id
365+ );
366 }
367368 // Update the closing span's formatted_range (the most recent one)
···400 return Ok(());
401 }
402403+ // Whitespace-only content (trailing spaces, newlines) should be emitted
404+ // as plain text, not wrapped in a hideable syntax span
405+ let is_whitespace_only = syntax.trim().is_empty();
406+407+ if is_whitespace_only {
408+ // Emit as plain text with tracking span (not hideable)
409+ let created_node = if self.current_node_id.is_none() {
410+ let node_id = self.gen_node_id();
411+ write!(&mut self.writer, "<span id=\"{}\">", node_id)?;
412+ self.begin_node(node_id);
413+ true
414+ } else {
415+ false
416+ };
417+418+ escape_html(&mut self.writer, syntax)?;
419420+ if created_node {
421+ self.write("</span>")?;
422+ self.end_node();
423+ }
424425+ // Record offset mapping but no syntax span info
426+ self.record_mapping(range.clone(), char_start..char_end);
427+ self.last_char_offset = char_end;
428+ self.last_byte_offset = range.end;
000000429 } else {
430+ // Real syntax - wrap in hideable span
431+ let syntax_type = classify_syntax(syntax);
432+ let class = match syntax_type {
433+ SyntaxType::Inline => "md-syntax-inline",
434+ SyntaxType::Block => "md-syntax-block",
435+ };
436+437+ // Generate unique ID for this syntax span
438+ let syn_id = self.gen_syn_id();
439+440+ // If we're outside any node, create a wrapper span for tracking
441+ let created_node = if self.current_node_id.is_none() {
442+ let node_id = self.gen_node_id();
443+ write!(
444+ &mut self.writer,
445+ "<span id=\"{}\" class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
446+ node_id, class, syn_id, char_start, char_end
447+ )?;
448+ self.begin_node(node_id);
449+ true
450+ } else {
451+ write!(
452+ &mut self.writer,
453+ "<span class=\"{}\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
454+ class, syn_id, char_start, char_end
455+ )?;
456+ false
457+ };
458459+ escape_html(&mut self.writer, syntax)?;
460+ self.write("</span>")?;
461462+ // Record syntax span info for visibility toggling
463+ self.syntax_spans.push(SyntaxSpanInfo {
464+ syn_id,
465+ char_range: char_start..char_end,
466+ syntax_type,
467+ formatted_range: None,
468+ });
469470+ // Record offset mapping for this syntax
471+ self.record_mapping(range.clone(), char_start..char_end);
472+ self.last_char_offset = char_end;
473+ self.last_byte_offset = range.end;
474475+ // Close wrapper if we created one
476+ if created_node {
477+ self.write("</span>")?;
478+ self.end_node();
479+ }
480 }
481 }
482 }
···617618 // Only add checkpoint if we've advanced
619 if char_range.end > last_checkpoint.0 {
620+ self.utf16_checkpoints
621+ .push((char_range.end, new_utf16_offset));
622 }
623624 let mapping = OffsetMapping {
···708709 write!(
710 &mut self.writer,
711+ "<span class=\"md-placeholder\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
712 syn_id, char_start, char_end
713 )?;
714 escape_html(&mut self.writer, trailing)?;
715 self.write("</span>")?;
716717 // Record syntax span info
718+ // self.syntax_spans.push(SyntaxSpanInfo {
719+ // syn_id,
720+ // char_range: char_start..char_end,
721+ // syntax_type: SyntaxType::Inline,
722+ // formatted_range: None,
723+ // });
724725 // Record mapping if we have a node
726 if let Some(ref node_id) = self.current_node_id {
···1017 let syn_id = self.gen_syn_id();
1018 write!(
1019 &mut self.writer,
1020+ "<span class=\"md-placeholder\" data-syn-id=\"{}\" data-char-start=\"{}\" data-char-end=\"{}\">",
1021 syn_id, char_start, char_end
1022 )?;
1023 escape_html(&mut self.writer, spaces)?;
1024 self.write("</span>")?;
10251026 // Record syntax span info
1027+ // self.syntax_spans.push(SyntaxSpanInfo {
1028+ // syn_id,
1029+ // char_range: char_start..char_end,
1030+ // syntax_type: SyntaxType::Inline,
1031+ // formatted_range: None,
1032+ // });
10331034 // Count this span as a child
1035 self.current_node_child_count += 1;
···1046 self.current_node_child_count += 1;
10471048 // After <br>, emit plain zero-width space for cursor positioning
1049+ self.write(" ")?;
1050+ //self.write("\u{200B}")?;
10511052 // Count the zero-width space text node as a child
1053 self.current_node_child_count += 1;
···1262 // Record paragraph start for boundary tracking
1263 // BUT skip if inside a list - list owns the paragraph boundary
1264 if self.list_depth == 0 {
1265+ self.current_paragraph_start =
1266+ Some((self.last_byte_offset, self.last_char_offset));
1267 }
12681269 let node_id = self.gen_node_id();
···1303 }
1304 } else {
1305 // Just > and maybe a space
1306+ (gt_pos + 1).min(raw_text.len())
1307 };
13081309 let syntax = &raw_text[gt_pos..syntax_end];
···1925 Ok(())
1926 }
1927 }
1928+ TagEnd::BlockQuote(_) => {
1929+ // If pending_blockquote_range is still set, the blockquote was empty
1930+ // (no paragraph inside). Emit the > as its own minimal paragraph.
1931+ if let Some(bq_range) = self.pending_blockquote_range.take() {
1932+ if bq_range.start < bq_range.end {
1933+ let raw_text = &self.source[bq_range.clone()];
1934+ if let Some(gt_pos) = raw_text.find('>') {
1935+ let para_byte_start = bq_range.start + gt_pos;
1936+ let para_char_start = self.last_char_offset;
1937+1938+ // Create a minimal paragraph for the empty blockquote
1939+ // let node_id = self.gen_node_id();
1940+ // write!(&mut self.writer, "<p id=\"{}\"", node_id)?;
1941+ // self.begin_node(node_id.clone());
1942+1943+ // // Record start-of-node mapping for cursor positioning
1944+ // self.offset_maps.push(OffsetMapping {
1945+ // byte_range: para_byte_start..para_byte_start,
1946+ // char_range: para_char_start..para_char_start,
1947+ // node_id: node_id.clone(),
1948+ // char_offset_in_node: 0,
1949+ // child_index: Some(0),
1950+ // utf16_len: 0,
1951+ // });
1952+1953+ // Emit the > as block syntax
1954+ let syntax = &raw_text[gt_pos..gt_pos + 1];
1955+ self.emit_inner_syntax(syntax, para_byte_start, SyntaxType::Block)?;
1956+1957+ // self.write("</p>\n")?;
1958+ // self.end_node();
1959+1960+ // Record paragraph boundary for incremental rendering
1961+ let byte_range = para_byte_start..bq_range.end;
1962+ let char_range = para_char_start..self.last_char_offset;
1963+ self.paragraph_ranges.push((byte_range, char_range));
1964+ }
1965+ }
1966+ }
1967+ self.write("</blockquote>\n")
1968+ }
1969 TagEnd::CodeBlock => {
1970 use std::sync::LazyLock;
1971 use syntect::parsing::SyntaxSet;
+25-1
crates/weaver-app/src/views/editor.rs
···1//! Editor view - wraps the MarkdownEditor component for the /editor route.
23-use dioxus::prelude::*;
4use crate::components::editor::MarkdownEditor;
056/// Editor page view.
7///
···10#[component]
11pub fn Editor() -> Element {
12 rsx! {
013 div { class: "editor-page",
14 MarkdownEditor { initial_content: None }
15 }
16 }
17}
00000000000000000000000
···1//! Editor view - wraps the MarkdownEditor component for the /editor route.
203use crate::components::editor::MarkdownEditor;
4+use dioxus::prelude::*;
56/// Editor page view.
7///
···10#[component]
11pub fn Editor() -> Element {
12 rsx! {
13+ EditorCss {}
14 div { class: "editor-page",
15 MarkdownEditor { initial_content: None }
16 }
17 }
18}
19+20+#[component]
21+pub fn EditorCss() -> Element {
22+ use weaver_renderer::css::{generate_base_css, generate_syntax_css};
23+ use weaver_renderer::theme::default_resolved_theme;
24+25+ let css_content = use_resource(move || async move {
26+ let resolved_theme = default_resolved_theme();
27+ let mut css = generate_base_css(&resolved_theme);
28+ css.push_str(
29+ &generate_syntax_css(&resolved_theme)
30+ .await
31+ .unwrap_or_default(),
32+ );
33+34+ Some(css)
35+ });
36+37+ match css_content() {
38+ Some(Some(css)) => rsx! { document::Style { {css} } },
39+ _ => rsx! {},
40+ }
41+}