diff --git i/app/helpers/application_helper.rb w/app/helpers/application_helper.rb index 8566ce026..18a56ebae 100644 --- i/app/helpers/application_helper.rb +++ w/app/helpers/application_helper.rb @@ -960,6 +960,7 @@ module ApplicationHelper text = text.dup macros = catch_macros(text) + contains_macros = macros.any? if options[:formatting] == false text = h(text) @@ -978,6 +979,7 @@ module ApplicationHelper end end parse_sections(text, project, obj, attr, only_path, options) + fix_paragraphs_with_block_level_content(text) if contains_macros parse_headings(text, project, obj, attr, only_path, options) if @parsed_headings.any? @@ -1498,6 +1500,76 @@ module ApplicationHelper end end + unless const_defined?(:PARAGRAPH_TAG_RE) + PARAGRAPH_TAG_RE = /]*)?>/i + PARAGRAPH_BOUNDARY_RE = %r{]*)?>}i + BLOCK_LEVEL_CONTENT_RE = + %r{<(?:address|blockquote|div|dl|fieldset|figure|form|h[1-6]|hr|ol|p|pre|section|table|ul)[\s/>]}i + end + + # Macros are replaced by a placeholder before the text is formatted and are + # executed afterwards, so a macro standing on its own line has its output + # injected into the paragraph the formatter built for that placeholder. + # Macros returning block-level content ({{include}}, {{collapse}} and macros + # provided by plugins) therefore produce block-level elements inside a

, + # which is invalid: browsers close the paragraph before the block element and + # turn the then orphaned

into a second, empty paragraph. Both show up as + # unexpected blank space around the macro output. + # Such paragraphs are turned into divs to keep the markup valid. + def fix_paragraphs_with_block_level_content(text) + return text unless text.include?('

') + + result = +'' + scanner = StringScanner.new(text) + until scanner.eos? + preceding = scanner.scan_until(PARAGRAPH_TAG_RE) + if preceding.nil? + result << scanner.rest + break + end + + opening_tag = scanner.matched + attributes = scanner[1] + result << preceding[0...-opening_tag.length] + content = scan_paragraph_content(scanner) + + if content.nil? + # Unbalanced paragraph, leave the remaining text untouched + result << opening_tag << scanner.rest + break + elsif content.match?(BLOCK_LEVEL_CONTENT_RE) + result << "" << content << '' + else + result << opening_tag << content << '

' + end + end + text.replace(result) + end + + # Returns the content of the paragraph the scanner has just entered and moves + # the scanner past the matching closing tag. Nested paragraphs are taken into + # account, so that a paragraph belonging to injected macro output does not end + # the enclosing one. Returns nil if the paragraph is never closed. + def scan_paragraph_content(scanner) + content = +'' + depth = 1 + until scanner.eos? + chunk = scanner.scan_until(PARAGRAPH_BOUNDARY_RE) + return nil if chunk.nil? + + tag = scanner.matched + content << chunk[0...-tag.length] + if tag.start_with?('\{\{((<|<)|(>|>))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE) # Renders the TOC with given headings