**not bold**
+ # 2. Use backslash escaping: \*\*not bold\*\* → renders as **not bold** (plain text)
+ MARKDOWN_PATTERNS = [
+ /\*\*[^*]+\*\*/, # Bold: **text**
+ /(?\s/m, # Blockquotes: > quote
+ /^---+$/m # Horizontal rule: ---
+ ].freeze
+
+ def self.has_markdown?(text)
+ MARKDOWN_PATTERNS.any? { |pattern| text.match?(pattern) }
+ end
+
+ def applicable?
+ has_markdown? && !has_attachments?
+ end
+
+ def apply
+ # Pre-process text to ensure lists are properly separated from surrounding content
+ preprocessed_text = normalize_lists(plain_text_content)
+
+ # Convert markdown to HTML using Redcarpet
+ markdown_html = self.class.markdown_renderer.render(preprocessed_text)
+
+ # Replace the entire fragment with the rendered markdown
+ fragment.update do |source|
+ source.inner_html = markdown_html
+ end
+ end
+
+ private
+ def has_markdown?
+ self.class.has_markdown?(plain_text_content)
+ end
+
+ def has_attachments?
+ # Skip markdown rendering if ActionText attachments are present
+ # to avoid destroying them when we replace inner_html
+ fragment.find_all("action-text-attachment").any?
+ end
+
+ def plain_text_content
+ fragment.to_plain_text
+ end
+
+ def normalize_lists(text)
+ # Add blank lines before and after lists to ensure Redcarpet renders them correctly
+ # even when they're surrounded by other text.
+ # This handles cases like:
+ # - "Hello\n* item 1\n* item 2" -> "Hello\n\n* item 1\n* item 2"
+ # - "* item 1\n* item 2\nGoodbye" -> "* item 1\n* item 2\n\nGoodbye"
+
+ lines = text.split("\n", -1)
+ result = []
+ in_list = false
+ list_pattern = /^(\*|-|\d+\.)\s/
+
+ lines.each_with_index do |line, i|
+ is_list_item = line.match?(list_pattern)
+ prev_line = i > 0 ? lines[i - 1] : nil
+
+ # Starting a list: add blank line before if previous line exists and isn't blank
+ if is_list_item && !in_list
+ if prev_line && !prev_line.strip.empty?
+ result << ""
+ end
+ in_list = true
+ end
+
+ # Ending a list: add blank line after
+ if !is_list_item && in_list && !line.strip.empty?
+ result << ""
+ in_list = false
+ end
+
+ # Exiting list at blank line
+ if line.strip.empty?
+ in_list = false
+ end
+
+ result << line
+ end
+
+ result.join("\n")
+ end
+
+ def self.markdown_renderer
+ @markdown_renderer ||= Redcarpet::Markdown.new(
+ Redcarpet::Render::HTML.new(
+ filter_html: true, # Strip HTML tags for security
+ safe_links_only: true, # Block javascript: and data: URLs
+ no_styles: true, # Remove inline styles
+ hard_wrap: true, # Convert single newlines to print\(\)<\/code>/
+ end
+
+ test "renders code blocks with triple backticks" do
+ code_block = "```\ndef hello():\n print('world')\n```"
+ filtered = apply_text_filters(code_block)
+ assert_match //, filtered.to_html
+ assert_match /def hello/, filtered.to_html
+ end
+
+ test "does not detect indented code blocks as markdown" do
+ # 4-space indentation doesn't trigger markdown detection (good - avoids false positives)
+ indented = "Here is code:\n\n def hello():\n print('world')"
+ # Should not be detected as markdown since no explicit markdown patterns
+ refute_markdown_applicable indented
+ end
+
+ test "renders headers" do
+ filtered = apply_text_filters("# Heading 1\n## Heading 2")
+ assert_match /Heading 1<\/h1>/, filtered.to_html
+ assert_match /Heading 2<\/h2>/, filtered.to_html
+ end
+
+ test "renders unordered lists with asterisks" do
+ filtered = apply_text_filters("* Item 1\n* Item 2\n* Item 3")
+ assert_match //, filtered.to_html
+ assert_match /- Item 1<\/li>/, filtered.to_html
+ end
+
+ test "renders unordered lists with hyphens" do
+ filtered = apply_text_filters("- Item 1\n- Item 2\n- Item 3")
+ assert_match /
/, filtered.to_html
+ assert_match /- Item 1<\/li>/, filtered.to_html
+ end
+
+ test "renders ordered lists" do
+ filtered = apply_text_filters("1. First\n2. Second\n3. Third")
+ assert_match /
/, filtered.to_html
+ assert_match /- First<\/li>/, filtered.to_html
+ end
+
+ test "renders markdown links" do
+ filtered = apply_text_filters("Check out [Basecamp](https://basecamp.com)")
+ assert_match /Basecamp<\/a>/, filtered.to_html
+ assert_match /target="_blank"/, filtered.to_html
+ assert_match /rel="noopener noreferrer"/, filtered.to_html
+ end
+
+ test "renders strikethrough text" do
+ assert_markdown_rendered "This is ~~deleted~~ text", /
deleted<\/del>/
+ end
+
+ test "renders blockquotes" do
+ filtered = apply_text_filters("> This is a quote")
+ assert_match //, filtered.to_html
+ assert_match /This is a quote/, filtered.to_html
+ end
+
+ test "renders horizontal rules" do
+ assert_markdown_rendered "Before\n\n---\n\nAfter", /
our site<\/strong>/, filtered.to_html
+ # Redcarpet's autolink should handle the plain URL
+ assert_match /bold<\/strong>/, filtered.to_html
+ assert_match /code<\/code>/, filtered.to_html
+ end
+
+ test "markdown takes precedence over auto_link for URLs" do
+ filtered = apply_text_filters("Visit **https://example.com** now")
+ # Should have markdown rendering, not auto_link
+ assert_match //, filtered.to_html
+ end
+
+ test "single line starting with asterisk does not trigger list detection" do
+ # Single lines starting with "* " DON'T trigger markdown (avoids false positives)
+ refute_markdown_applicable "* walks into room", "Single lines starting with '* ' should not trigger markdown detection"
+ end
+
+ test "single line starting with hyphen does not trigger list detection" do
+ # Single lines starting with "- " DON'T trigger markdown (avoids false positives)
+ refute_markdown_applicable "- Not sure about that", "Single lines starting with '- ' should not trigger markdown detection"
+ end
+
+ test "multiple list items trigger markdown detection" do
+ # Multiple list items (2+) DO trigger markdown detection
+ assert_markdown_applicable "* Item 1\n* Item 2", "Multiple list items should trigger markdown detection"
+ end
+
+ test "list with text before it triggers markdown detection" do
+ assert_markdown_applicable "Hello\n* Item 1\n* Item 2", "List with text before should trigger markdown detection"
+ end
+
+ test "list with text after it triggers markdown detection" do
+ assert_markdown_applicable "* Item 1\n* Item 2\nGoodbye", "List with text after should trigger markdown detection"
+ end
+
+ test "list with text before and after triggers markdown detection" do
+ assert_markdown_applicable "Hello\n* Item 1\n* Item 2\nGoodbye", "List with text before and after should trigger markdown detection"
+ end
+
+ test "renders list with text before it correctly" do
+ filtered = apply_text_filters("Hello\n- Item 1\n- Item 2")
+ assert_match //, filtered.to_html
+ assert_match /- Item 1<\/li>/, filtered.to_html
+ assert_match /
- Item 2<\/li>/, filtered.to_html
+ assert_match /Hello/, filtered.to_html
+ end
+
+ test "renders list with text after it correctly" do
+ filtered = apply_text_filters("- Item 1\n- Item 2\nGoodbye")
+ assert_match /
/, filtered.to_html
+ assert_match /- Item 1<\/li>/, filtered.to_html
+ assert_match /
- Item 2<\/li>/, filtered.to_html
+ assert_match /Goodbye/, filtered.to_html
+ end
+
+ test "renders list with text before and after correctly" do
+ filtered = apply_text_filters("Hello\n- Item 1\n- Item 2\nGoodbye")
+ assert_match /
/, filtered.to_html
+ assert_match /- Item 1<\/li>/, filtered.to_html
+ assert_match /
- Item 2<\/li>/, filtered.to_html
+ assert_match /Hello/, filtered.to_html
+ assert_match /Goodbye/, filtered.to_html
+ end
+
+ # Newline preservation tests
+
+ test "preserves single newlines with markdown" do
+ filtered = apply_text_filters("Line 1 with **bold**\nLine 2 normal")
+ # Should have a
tag to preserve the line break
+ assert_match /
/, filtered.to_html
+ assert_match /bold<\/strong>/, filtered.to_html
+ end
+
+ test "preserves multiple single newlines with markdown" do
+ filtered = apply_text_filters("Line 1 with **bold**\nLine 2 with *italic*\nLine 3 normal")
+ # Should have
tags for line breaks
+ assert_match /bold<\/strong>
/, filtered.to_html
+ assert_match /italic<\/em>
/, filtered.to_html
+ end
+
+ test "converts double newlines to paragraph breaks" do
+ filtered = apply_text_filters("Paragraph 1 with **bold**\n\nParagraph 2 normal")
+ # Should have separate paragraphs
+ assert_match /<\/p>\s*/, filtered.to_html
+ assert_match /bold<\/strong>/, filtered.to_html
+ end
+
+ # Escaping markdown syntax
+ test "escape markdown with backticks for literal characters" do
+ filtered = apply_text_filters("Use `**bold**` for bold text")
+ # The **bold** inside backticks should be rendered as code, not as bold
+ assert_match /\*\*bold\*\*<\/code>/, filtered.to_html
+ refute_match /bold<\/strong>/, filtered.to_html
+ end
+
+ test "backslash escaping works for markdown characters" do
+ # Backslash escaping DOES work - backslashes prevent markdown rendering
+ filtered = apply_text_filters("This is \\*\\*not bold\\*\\*")
+ refute_match //, filtered.to_html
+ assert_match /\*\*not bold\*\*/, filtered.to_html
+ refute_match /\\/, filtered.to_html, "Backslashes should be removed from output"
+ end
+
+ test "preserves ActionText attachments with markdown" do
+ # When attachments are present, markdown is NOT rendered to avoid destroying attachments
+ body = "Check **this** #{mention_attachment_for(:david)}"
+ message = Message.create! room: rooms(:pets), body: body, creator: users(:jason)
+
+ filtered = ContentFilters::TextMessagePresentationFilters.apply(message.body.body)
+
+ # Markdown should NOT be rendered when attachments are present
+ refute_match /this<\/strong>/, filtered.to_html
+ # But attachment should be preserved
+ assert_match /action-text-attachment/, filtered.to_html
+ assert_match /#{users(:david).attachable_sgid}/, filtered.to_html
+ # Markdown syntax remains as-is
+ assert_match /\*\*this\*\*/, filtered.to_html
+ end
+
+ # Security tests
+ test "prevents XSS attacks with script tags" do
+ filtered = apply_text_filters("**bold** ")
+ refute_match /