Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions UPGRADING.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Upgrading Markbridge

## 0.4.1 — setext underlines after hard line breaks are escaped

Text that directly follows a `LineBreak` (an HTML `<br>`) is now escaped
as a continuation of the paragraph above it. A line consisting only of
`=` or `-` there used to pass through unescaped and cooked as a setext
heading underline, turning the whole preceding paragraph into an `<h1>`
or `<h2>`:

```ruby
Markbridge.html_to_markdown("<p>Body:{}<br>========</p>").markdown
# 0.4.0: "Body:{}\n========" (renders as one big heading)
# 0.4.1: "Body:{}\n\\=\\=\\=\\=\\=\\=\\=\\=" (renders as the literal separator)
```

`MarkdownEscaper#escape` gained an `after_paragraph_line:` keyword and
the renderer always passes it; `IdentityEscaper#escape` accepts and
ignores it. A custom escaper supplied via `Renderer.new(escaper:)` must
accept the keyword (ignoring it is fine). `RenderContext` exposes the
state as `after_line_break?` / `with_after_line_break(value)`.

## 0.4.0 — ancestry matching and forced code blocks

### AST subclasses inherit rules and tags from their base class
Expand Down
5 changes: 4 additions & 1 deletion lib/markbridge/renderers/discourse/identity_escaper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ class IdentityEscaper
# broken link.
# @return [String] +text+ with +]+ optionally escaped, or
# +""+ when +text+ is nil
def escape(text, in_link_label: false)
# Other {MarkdownEscaper#escape} options (such as
# +after_paragraph_line:+) are accepted and ignored: trusted Markdown
# is never escaped.
def escape(text, in_link_label: false, **)
return "" if text.nil?
return text.gsub("]", "\\]") if in_link_label && text.include?("]")

Expand Down
15 changes: 10 additions & 5 deletions lib/markbridge/renderers/discourse/markdown_escaper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,22 @@ def initialize(escape_hard_line_breaks: false, allow: nil)
# can be spliced into a Markdown link label `[text](url)` without
# terminating it early. The default leaves `]` alone because a bare
# `]` in prose is harmless (the matching `[` is already escaped).
# @param after_paragraph_line [Boolean] when true, treat the first line
# of `text` as continuing a paragraph whose previous line was rendered
# separately (the renderer sets this for text that directly follows a
# hard line break). A line of `=` or `-` there would otherwise cook as
# a setext heading underline for everything above it.
# @return [String] the escaped text, or empty string if input is nil
# @note Multi-line HTML tags and blocks are handled by escaping the opening <
def escape(text, in_link_label: false)
def escape(text, in_link_label: false, after_paragraph_line: false)
return "" if text.nil?

# Neutralize hard line breaks (trailing 2+ spaces before newline)
text = text.gsub(/ +\n/, "\n") if @escape_hard_line_breaks && text.include?(" \n")

result =
if MAYBE_SPECIAL.match?(text) || MAYBE_INDENTED_CODE.match?(text)
escape_text(text)
escape_text(text, after_paragraph_line)
else
text
end
Expand Down Expand Up @@ -177,12 +182,12 @@ def resolve_allow(allow)
keys
end

def escape_text(text)
def escape_text(text, after_paragraph_line = false)
# Single-line fast path (the common case for inline text nodes):
# skip the split and its Array + line-String allocations. A lone
# `\r` without `\n` stays on the line either way — `/\r?\n/`
# needs the `\n` — so `include?("\n")` alone decides correctly.
return escape_line(text, false) unless text.include?("\n")
return escape_line(text, after_paragraph_line) unless text.include?("\n")

# On CRLF input, consume `\r` as part of the line terminator instead
# of leaving it on the line. A trailing `\r` breaks line-end anchored
Expand All @@ -196,7 +201,7 @@ def escape_text(text)
# Pre-allocate result buffer
bytesize = text.bytesize
result = String.new(capacity: bytesize + bytesize / 3, encoding: text.encoding)
prev_was_paragraph = false
prev_was_paragraph = after_paragraph_line
first = true

lines.each do |line|
Expand Down
41 changes: 38 additions & 3 deletions lib/markbridge/renderers/discourse/render_context.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,15 @@ class RenderContext
# @param html_mode [Boolean] see {#html_mode?}
# @param parent [RenderContext, nil] enclosing context (chain form)
# @param element [AST::Element, nil] nearest parent element (chain form)
def initialize(parents = [], html_mode: false, parent: nil, element: nil)
def initialize(
parents = [],
html_mode: false,
parent: nil,
element: nil,
after_line_break: false
)
@html_mode = html_mode
@after_line_break = after_line_break
if element
@parent_context = parent
@element = element
Expand Down Expand Up @@ -63,21 +70,49 @@ def parents
# @param element [AST::Element]
# @return [RenderContext]
def with_parent(element)
self.class.new(html_mode: @html_mode, parent: self, element:)
self.class.new(
html_mode: @html_mode,
parent: self,
element:,
after_line_break: @after_line_break,
)
end

# Create new context with html_mode toggled.
# @param value [Boolean]
# @return [RenderContext]
def with_html_mode(value)
self.class.new(html_mode: value, parent: @parent_context, element: @element)
self.class.new(
html_mode: value,
parent: @parent_context,
element: @element,
after_line_break: @after_line_break,
)
end

# Marks the node rendered under this context as the first thing after a
# hard line break, so a text escaper can treat its first line as
# continuing the paragraph above (setext underline protection).
def with_after_line_break(value)
return self if value == @after_line_break

self.class.new(
html_mode: @html_mode,
parent: @parent_context,
element: @element,
after_line_break: value,
)
end

# @return [Boolean]
def html_mode?
@html_mode
end

def after_line_break?
@after_line_break
end

# Find closest parent that is_a? klass (handles subclasses).
# The chain walks are inlined in each query (instead of a shared
# yielding helper) — these run several times per rendered text
Expand Down
13 changes: 11 additions & 2 deletions lib/markbridge/renderers/discourse/renderer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,13 @@ def render_default(node, context: RenderContext.new)
# @return [String]
def render_children(node, context:)
result = +""
# The first child inherits the caller's line-break state (so text
# wrapped in inline markup right after a <br> still sees it); later
# children see it only when their previous sibling was a LineBreak.
child_context = context
node.children.each do |child|
part = render(child, context:)
part = render(child, context: child_context)
child_context = context.with_after_line_break(child.is_a?(AST::LineBreak))
next if part.empty?

# Integer-byte check avoids allocating substrings for the
Expand Down Expand Up @@ -179,7 +184,11 @@ def render_text(node, context)
elsif context.html_mode?
@html_escaper.escape(node.text)
else
@escaper.escape(node.text, in_link_label: in_link_label?(context))
@escaper.escape(
node.text,
in_link_label: in_link_label?(context),
after_paragraph_line: context.after_line_break?,
)
end
end

Expand Down
12 changes: 12 additions & 0 deletions spec/markbridge_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,18 @@ def render(_e, _i)
end

describe ".html_to_markdown" do
it "escapes a separator line after <br> so it does not cook as a setext heading" do
result = described_class.html_to_markdown("<p>Body:{}<br>========</p>")

expect(result.markdown).to eq("Body:{}\n\\=\\=\\=\\=\\=\\=\\=\\=")
end

it "keeps a separator line in its own paragraph unescaped" do
result = described_class.html_to_markdown("<p>Body:{}</p><p>========</p>")

expect(result.markdown).to eq("Body:{}\n\n========")
end

it "normalizes the AST by default" do
conversion = described_class.html_to_markdown("<b>hi</b>") { |ast| append_nested_link(ast) }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
expect(escaper.escape(input)).to be(input)
end

it "accepts and ignores after_paragraph_line (parity with MarkdownEscaper#escape)" do
expect(escaper.escape("===", after_paragraph_line: true)).to eq("===")
end

it "returns an empty string for nil (parity with MarkdownEscaper#escape)" do
expect(escaper.escape(nil)).to eq("")
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@
# NOTE: Discourse converts -- to &ndash;, so we must escape each dash
# individually to prevent issues like \-&ndash;

context "when the caller reports a preceding paragraph line (after_paragraph_line: true)" do
it "escapes a bare = line" do
expect(escaper.escape("===", after_paragraph_line: true)).to eq("\\=\\=\\=")
end

it "escapes a bare - line with each dash escaped" do
expect(escaper.escape("--", after_paragraph_line: true)).to eq("\\-\\-")
end

it "seeds only the first line of multi-line text" do
expect(escaper.escape("===\nText\n===", after_paragraph_line: true)).to eq(
"\\=\\=\\=\nText\n\\=\\=\\=",
)
end

it "does not escape a bare = line by default" do
expect(escaper.escape("===")).to eq("===")
end
end

context "when = or - line follows paragraph (MUST escape)" do
it "escapes = underline after paragraph" do
text = "Heading\n==="
Expand Down
15 changes: 15 additions & 0 deletions spec/unit/markbridge/renderers/discourse/markdown_escaper_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@
subject(:escaper) { described_class.new }

describe "#escape" do
describe "after_paragraph_line:" do
it "escapes a single-line setext underline when the caller reports a preceding paragraph line" do
expect(escaper.escape("===", after_paragraph_line: true)).to eq("\\=\\=\\=")
end

it "seeds the paragraph state for the first line of multi-line text" do
expect(escaper.escape("===\nText", after_paragraph_line: true)).to eq("\\=\\=\\=\nText")
end

it "defaults to no preceding paragraph line" do
expect(escaper.escape("===")).to eq("===")
expect(escaper.escape("===\nText")).to eq("===\nText")
end
end

# Helper to verify escaped output renders as literal text
# The escaper MAY over-escape (false positives allowed), but MUST escape
# anything that would otherwise be interpreted as Markdown (no false negatives)
Expand Down
33 changes: 33 additions & 0 deletions spec/unit/markbridge/renderers/discourse/render_context_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@
expect(context.parents).to eq([])
end

it "is not after a line break by default" do
expect(described_class.new.after_line_break?).to be(false)
end

it "stores after_line_break: when given" do
expect(described_class.new(after_line_break: true).after_line_break?).to be(true)
end

it "creates context with given parents" do
bold = Markbridge::AST::Bold.new
italic = Markbridge::AST::Italic.new
Expand Down Expand Up @@ -399,6 +407,31 @@
end
end

describe "#with_after_line_break" do
it "defaults to false" do
expect(described_class.new.after_line_break?).to be(false)
end

it "returns a context flagged as following a hard line break" do
context = described_class.new.with_after_line_break(true)
expect(context.after_line_break?).to be(true)
end

it "returns self when the flag is unchanged" do
context = described_class.new
expect(context.with_after_line_break(false)).to equal(context)
end

it "keeps the flag across with_parent and with_html_mode" do
bold = Markbridge::AST::Bold.new
context = described_class.new.with_after_line_break(true)

expect(context.with_parent(bold).after_line_break?).to be(true)
expect(context.with_parent(bold).parents).to eq([bold])
expect(context.with_html_mode(true).after_line_break?).to be(true)
end
end

describe "#with_html_mode" do
it "returns a new context with html_mode set" do
context = described_class.new
Expand Down
49 changes: 48 additions & 1 deletion spec/unit/markbridge/renderers/discourse/renderer_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,11 @@
result = described_class.new(escaper:).render(Markbridge::AST::Text.new("hi"))

expect(result).to eq("ESCAPED")
expect(escaper).to have_received(:escape).with("hi", in_link_label: false)
expect(escaper).to have_received(:escape).with(
"hi",
in_link_label: false,
after_paragraph_line: false,
)
end

it "falls back to TagLibrary.default when no tag_library is provided" do
Expand Down Expand Up @@ -304,6 +308,49 @@
expect(result).to eq("")
end

it "escapes a setext underline that directly follows a hard line break" do
paragraph = Markbridge::AST::Paragraph.new
paragraph << Markbridge::AST::Text.new("Body:{}")
paragraph << Markbridge::AST::LineBreak.new
paragraph << Markbridge::AST::Text.new("========")

context = Markbridge::Renderers::Discourse::RenderContext.new
expect(renderer.render_children(paragraph, context:)).to eq(
"Body:{}\n\\=\\=\\=\\=\\=\\=\\=\\=",
)
end

it "escapes a short dash underline after a hard line break" do
paragraph = Markbridge::AST::Paragraph.new
paragraph << Markbridge::AST::Text.new("Heading")
paragraph << Markbridge::AST::LineBreak.new
paragraph << Markbridge::AST::Text.new("--")

context = Markbridge::Renderers::Discourse::RenderContext.new
expect(renderer.render_children(paragraph, context:)).to eq("Heading\n\\-\\-")
end

it "carries the line-break state into inline markup wrapping the next text" do
paragraph = Markbridge::AST::Paragraph.new
paragraph << Markbridge::AST::Text.new("Heading")
paragraph << Markbridge::AST::LineBreak.new
italic = Markbridge::AST::Italic.new
italic << Markbridge::AST::Text.new("===")
paragraph << italic

context = Markbridge::Renderers::Discourse::RenderContext.new
expect(renderer.render_children(paragraph, context:)).to eq("Heading\n*\\=\\=\\=*")
end

it "leaves an equals line alone when nothing precedes it on the line" do
paragraph = Markbridge::AST::Paragraph.new
paragraph << Markbridge::AST::Text.new("Heading ")
paragraph << Markbridge::AST::Text.new("===")

context = Markbridge::Renderers::Discourse::RenderContext.new
expect(renderer.render_children(paragraph, context:)).to eq("Heading ===")
end

it "checks against the part's FIRST char when deciding boundary insertion" do
# Custom tag whose output starts with `*` but ends with non-delimiter `Z`.
# Combined with a previous sibling ending in `*`, the boundary must be
Expand Down