Project

General

Profile

Defect #43730 » 0001-fix-block-level-macro-content-in-paragraph.diff

Alexander Meindl, 2026-07-28 18:00

View differences:

w/app/helpers/application_helper.rb
960 960

  
961 961
    text = text.dup
962 962
    macros = catch_macros(text)
963
    contains_macros = macros.any?
963 964

  
964 965
    if options[:formatting] == false
965 966
      text = h(text)
......
978 979
      end
979 980
    end
980 981
    parse_sections(text, project, obj, attr, only_path, options)
982
    fix_paragraphs_with_block_level_content(text) if contains_macros
981 983
    parse_headings(text, project, obj, attr, only_path, options)
982 984

  
983 985
    if @parsed_headings.any?
......
1498 1500
    end
1499 1501
  end
1500 1502

  
1503
  unless const_defined?(:PARAGRAPH_TAG_RE)
1504
    PARAGRAPH_TAG_RE = /<p(\s[^>]*)?>/i
1505
    PARAGRAPH_BOUNDARY_RE = %r{</?p(?:\s[^>]*)?>}i
1506
    BLOCK_LEVEL_CONTENT_RE =
1507
      %r{<(?:address|blockquote|div|dl|fieldset|figure|form|h[1-6]|hr|ol|p|pre|section|table|ul)[\s/>]}i
1508
  end
1509

  
1510
  # Macros are replaced by a placeholder before the text is formatted and are
1511
  # executed afterwards, so a macro standing on its own line has its output
1512
  # injected into the paragraph the formatter built for that placeholder.
1513
  # Macros returning block-level content ({{include}}, {{collapse}} and macros
1514
  # provided by plugins) therefore produce block-level elements inside a <p>,
1515
  # which is invalid: browsers close the paragraph before the block element and
1516
  # turn the then orphaned </p> into a second, empty paragraph. Both show up as
1517
  # unexpected blank space around the macro output.
1518
  # Such paragraphs are turned into divs to keep the markup valid.
1519
  def fix_paragraphs_with_block_level_content(text)
1520
    return text unless text.include?('</p>')
1521

  
1522
    result = +''
1523
    scanner = StringScanner.new(text)
1524
    until scanner.eos?
1525
      preceding = scanner.scan_until(PARAGRAPH_TAG_RE)
1526
      if preceding.nil?
1527
        result << scanner.rest
1528
        break
1529
      end
1530

  
1531
      opening_tag = scanner.matched
1532
      attributes = scanner[1]
1533
      result << preceding[0...-opening_tag.length]
1534
      content = scan_paragraph_content(scanner)
1535

  
1536
      if content.nil?
1537
        # Unbalanced paragraph, leave the remaining text untouched
1538
        result << opening_tag << scanner.rest
1539
        break
1540
      elsif content.match?(BLOCK_LEVEL_CONTENT_RE)
1541
        result << "<div#{attributes}>" << content << '</div>'
1542
      else
1543
        result << opening_tag << content << '</p>'
1544
      end
1545
    end
1546
    text.replace(result)
1547
  end
1548

  
1549
  # Returns the content of the paragraph the scanner has just entered and moves
1550
  # the scanner past the matching closing tag. Nested paragraphs are taken into
1551
  # account, so that a paragraph belonging to injected macro output does not end
1552
  # the enclosing one. Returns nil if the paragraph is never closed.
1553
  def scan_paragraph_content(scanner)
1554
    content = +''
1555
    depth = 1
1556
    until scanner.eos?
1557
      chunk = scanner.scan_until(PARAGRAPH_BOUNDARY_RE)
1558
      return nil if chunk.nil?
1559

  
1560
      tag = scanner.matched
1561
      content << chunk[0...-tag.length]
1562
      if tag.start_with?('</')
1563
        depth -= 1
1564
        return content if depth.zero?
1565
      else
1566
        depth += 1
1567
      end
1568
      content << tag
1569
    end
1570
    nil
1571
  end
1572

  
1501 1573
  TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
1502 1574

  
1503 1575
  # Renders the TOC with given headings
(13-13/15)