Skip to content

Add Markdown text escaping - #166

Merged
thibaudcolas merged 17 commits into
mainfrom
markdown-escaping
Aug 5, 2026
Merged

Add Markdown text escaping#166
thibaudcolas merged 17 commits into
mainfrom
markdown-escaping

Conversation

@thibaudcolas

Copy link
Copy Markdown
Member

Adds context-aware CommonMark escaping of user-controlled text and link destinations to the experimental Markdown exporter, closing several content-injection vectors described in the project's threat model (docs/SECURITY.md): ContentState is attacker-controlled, and previously any block text or entity data reached the Markdown output verbatim.

What changes

  • Text escaping in the Markdown engine. The engine now escapes plain-string children at render time under one invariant: a plain str child is always user text; structural syntax is wrapped in mark_safe elements by components. Anywhere-escaped: \, `, *, _, [, ], <, &. Line-start-escaped: #, -, +, >, =, |, ~, and ordered-list markers (1.), where line start means start of block content, after any line ending (\n, \r\n, \r), after any leading run of spaces, or after a list/blockquote marker.
  • Link/image URLs get destination-specific escaping: \, (, ) are backslash-escaped, whitespace/control characters percent-encoded (prevents ](…) breakout). URL scheme validation remains the integrator's responsibility, as documented in SECURITY.md.
  • Code spans and code blocks are never escaped — instead their delimiters are sized to the content (a code block containing ``` gets a longer fence; inline code containing a backtick gets double backticks). This also fixes pre-existing output corruption for such content.
  • Always on, no configuration option. Markdown support is documented as experimental; output containing metacharacters changes accordingly (see the migration guide entry).

How to verify

  • just test — 543 tests passing, including new unit, engine, integration (tests/markdown/test_escaping.py), and Hypothesis property tests.
  • just test-coverage — 100% line and branch coverage.
  • just lint — clean (ruff, mypy, ty).
  • Example: block text # not a heading now exports as \# not a heading; <script>alert(1)</script> as \<script>alert(1)\</script>; a link entity URL https://example.com/a(b) as [click](https://example.com/a\(b\)).

Notes for reviewers

  • Security reviewed against the STRIDE sections of docs/SECURITY.md; a dedicated adversarial review round verified escaping cannot be bypassed via line endings, leading spaces, entities spanning line starts, code delimiter content, or list/blockquote nesting.
  • docs/markdown.md gains an "Escaping" section documenting the rules and residual limitations (leading 4+-space/tab indentation can still render as an indented code block — a fidelity edge case, not an injection vector).

  • Stay on point and keep it small so it can be easily reviewed. For example, try to apply any general refactoring separately outside of the PR.
  • Consider adding unit tests, especially for bug fixes. If you don't, tell us why.
  • All new and existing tests pass, with 100% test coverage (just test-coverage)
  • Linting passes (just lint)
  • Consider updating documentation. If you don't, tell us why.
  • List the environments / platforms in which you tested your changes. — macOS, Python 3.14.

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🔍 OpenCodeReview found 19 issue(s) in this PR.

  • ✅ Successfully posted inline: 19 comment(s)

Comment on lines +119 to +122
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ordinal comparison char > " " relies on implicit string-to-ordinal coercion. While correct for ASCII, consider using ord(char) > 32 for clarity. Additionally, \x7f (DEL, 127) is explicitly excluded from percent-encoding but not documented. The docstring should clarify that DEL is intentionally preserved.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review. DEL is in fact percent-encoded here: the passthrough condition requires char != "\x7f", so \x7f takes the encoding branch — the docstring's "control characters" covers it. On the char > " " idiom: agreed ord() would be more explicit, but the comparison is correct for single characters and we're keeping it as-is.

"""
return lambda props: block([prefix, props["children"]])
return lambda props: block(
[mark_safe(prefix, block_prefix=block_prefix), props["children"]]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type mismatch: mark_safe passes block_prefix as a Python bool, but engines/markdown.py:157 compares it to the string "true". This means block_prefix=True will never match, and at_line_start will never be set to True after a mark_safe element with block_prefix=True. This breaks the nested block behavior for blockquotes and list indentation. Fix by comparing to the boolean True instead.

Suggestion:

Suggested change
[mark_safe(prefix, block_prefix=block_prefix), props["children"]]
c.attr.get("block_prefix") is True

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be a real bug if attributes weren't stringified, but DOM.create_element coerces True to the string "true" before create_tag sees it, and the behavior is pinned end-to-end by test_line_start_in_list_item and test_line_start_in_blockquote in tests/markdown/test_escaping.py (both fail if the comparison stops matching). Not a defect.

Comment on lines +162 to +165
else:
out.append(escape_text(c, at_line_start))
if c:
at_line_start = c.endswith(("\n", "\r"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty string children don't update at_line_start, causing consecutive empty strings to inherit stale state. This could lead to incorrect escaping for empty strings that should be at line start but aren't marked as such. Consider using 'if not c or c.endswith(('\n', '\r'))' to handle empty strings correctly (they don't change line start state but shouldn't break tracking).

Suggestion:

Suggested change
else:
out.append(escape_text(c, at_line_start))
if c:
at_line_start = c.endswith(("\n", "\r"))
else:
out.append(escape_text(c, at_line_start))
if c.endswith(('\n', '\r')):
at_line_start = True

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inheriting state after an empty string is intentional: nothing was emitted, so line-start state must not change. The suggested change would set at_line_start=True after empty strings mid-line (e.g. right after a "**" marker), producing false-positive escapes. Current behavior is correct.

Comment thread tests/markdown/test_helpers.py Outdated
Comment on lines +16 to +17
def test_renders_verbatim(self):
self.assertEqual(DOM.render(inline([mark_safe("# "), "x"])), "# x")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test passes coincidentally because 'x' has no Markdown metacharacters. Consider using a value with special characters (e.g., 'bold' or 'italic') to more robustly verify that: (1) mark_safe content renders verbatim, and (2) plain strings are still properly escaped after mark_safe content.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — addressed in ae50109: the test now uses "x", so it verifies both verbatim mark_safe rendering and escaping of the following plain string.

Comment on lines +70 to +75
class TestMarkdownEscaping(unittest.TestCase):
def setUp(self):
self.exporter = HTML(MARKDOWN_CONFIG)

def render(self, *args: Any, **kwargs: Any) -> str:
return self.exporter.render(content_state(*args, **kwargs))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In render_children, block_prefix is compared as a string (== "true"), but mark_safe passes the boolean directly to DOM.create_element. If DOM.create_element does not coerce booleans to lowercase strings, the condition c.attr.get("block_prefix") == "true" would never match, causing line-start escaping to fail after block prefixes.

Evidence: test_helpers.py tests that elt.attr["block_prefix"] equals the string "true" (line 22) and "false" (line 26), confirming the expected stored type. The implementation should either: (1) ensure mark_safe stores block_prefix as a string (str(block_prefix)), or (2) use a boolean comparison in render_children (c.attr.get("block_prefix") is True).

Suggestion:

Suggested change
class TestMarkdownEscaping(unittest.TestCase):
def setUp(self):
self.exporter = HTML(MARKDOWN_CONFIG)
def render(self, *args: Any, **kwargs: Any) -> str:
return self.exporter.render(content_state(*args, **kwargs))
if (
c.type == "mark_safe"
and c.attr
and str(c.attr.get("block_prefix")) == "true"
):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as the other block_prefix comment: DOM.create_element stringifies booleans to "true"/"false", which the helper tests assert directly. Note the suggested str(...) comparison would actually break if a raw bool ever reached the attr, since str(True) is "True", not "true".

Comment on lines +152 to 153
"element": make_code_element(),
"wrapper": make_code_wrapper(fence),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The make_code_element() function no longer accepts a fence parameter (line 151), but the default export code_element is still constructed via make_code_element() with no arguments, which is consistent. This change removes the deprecated fence parameter from the element level, with fence handling now centralized in make_code_wrapper.

Suggestion:

Suggested change
"element": make_code_element(),
"wrapper": make_code_wrapper(fence),
"element": make_code_element(),
"wrapper": make_code_wrapper(fence),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This suggestion matches the code as written — nothing to change.

"wrapper": list_wrapper,
},
BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> "),
BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> ", block_prefix=True),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block_prefix=True parameter for blockquotes is correctly added in both BLOCK_MAP and build_markdown_config. However, verify that the escaping behavior after blockquote prefixes handles nested constructs correctly (e.g., a line like # Title inside a blockquote should be escaped to \# Title).

Suggestion:

Suggested change
BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> ", block_prefix=True),
BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> ", block_prefix=True),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already the case as written, and the nested-construct behavior is pinned by test_line_start_in_blockquote in tests/markdown/test_escaping.py.

Comment on lines +103 to +122
def escape_link_destination(url: str) -> str:
"""Escape a URL for use as an inline link destination inside ``](…)``.

Backslash-escapes backslashes and parentheses (which would otherwise
break out of the destination), and percent-encodes ASCII whitespace and
control characters, which inline destinations may not contain. URL
scheme validation is out of scope: it is the integrator's
responsibility (see ``docs/SECURITY.md``).

Parameters:
url: The URL to escape.

Returns:
The escaped destination.
"""
escaped = url.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function does not escape backticks (`). While CommonMark permits literal backticks in link destinations (](url)), there is no isolation between URL content and code span delimiter computation. A URL like https://example.com/a`````b could cause code_span_delimiters to generate unexpectedly long delimiters. Consider escaping backticks here alongside ( and ) to ensure URL content cannot influence delimiter sizing.

Suggestion:

Suggested change
def escape_link_destination(url: str) -> str:
"""Escape a URL for use as an inline link destination inside ``](…)``.
Backslash-escapes backslashes and parentheses (which would otherwise
break out of the destination), and percent-encodes ASCII whitespace and
control characters, which inline destinations may not contain. URL
scheme validation is out of scope: it is the integrator's
responsibility (see ``docs/SECURITY.md``).
Parameters:
url: The URL to escape.
Returns:
The escaped destination.
"""
escaped = url.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)
def escape_link_destination(url: str) -> str:
"""Escape a URL for use as an inline link destination inside ``](…)``.
Backslash-escapes backslashes, parentheses, and backticks (which would
otherwise break out of the destination or interfere with code span
delimiter sizing), and percent-encodes ASCII whitespace and control
characters, which inline destinations may not contain. URL scheme
validation is out of scope: it is the integrator's responsibility
(see ``docs/SECURITY.md``).
Parameters:
url: The URL to escape.
Returns:
The escaped destination.
"""
escaped = (
url.replace("\\", "\\\\")
.replace("(", "\\(")
.replace(")", "\\)")
.replace("`", "\\`")
)
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link destinations never pass through code_span_delimiters — code span delimiters are sized from the code span's own text content only, so a backtick in a URL cannot influence delimiter sizing. Not a defect.

Comment on lines +119 to +122
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition char > " " uses lexicographic string comparison. For single ASCII characters this is equivalent to ord(char) > 32, but the intent is implicit. Using ord(char) > 32 makes the comparison explicit and is the more common idiom in this codebase's sibling modules (e.g., ord(char):02X on the same line).

Suggestion:

Suggested change
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)
return "".join(
char if ord(char) > 32 and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same note as the earlier round's comment on this line: keeping char > " " — it is correct for single characters.

A fragment containing the children and a trailing blank line.
"""
return DOM.create_element("fragment", {}, children + ["\n\n"])
return DOM.create_element("fragment", {}, children + [mark_safe("\n\n")])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block function always appends a mark_safe("\n\n") element to children. If children already contains a trailing mark_safe with newlines (e.g., from a code block), this would result in multiple blank lines. Consider checking if the last child already ends with a mark_safe containing trailing newlines to avoid duplication.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

block() is only used by paragraph-like components; code blocks render via the code_block node and never pass through block(), so this duplication cannot occur with the current component set. A hypothetical misuse would produce a cosmetic extra blank line at worst.

@@ -0,0 +1,258 @@
"""Integration tests for Markdown escaping at the exporter level."""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The file is listed as test_escape.py in the modified files list but the diff shows test_escaping.py. Verify the file was renamed intentionally and the test file exists at the correct location.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both files exist intentionally: tests/markdown/test_escape.py covers the escaping primitives, tests/markdown/test_escaping.py covers exporter-level integration.

Comment on lines 33 to 38
class TestCodeElement(unittest.TestCase):
def test_works(self):
def test_renders_line_with_newline(self):
self.assertEqual(
DOM.render(
code_element(
{
"block": {},
"children": "test",
}
)
),
DOM.render(code_element({"block": {}, "children": "test"})),
"test\n",
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed tests test_block_end and test_tilde_fence_block_end verified that closing fences are rendered when a code block ends. While the closing fence is still rendered (now handled by the code_block node in the Markdown engine), the unit test coverage for this behavior in code_element and make_code_element has been removed. The new integration tests (TestCodeBlockExport) cover the full rendering but don't directly test the component functions themselves. If the component-to-engine contract is accidentally broken (e.g., content not reaching code_block), this would not be caught by unit tests.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage moved levels deliberately: the component contract is pinned by test_renders_line_with_newline, and closing-fence rendering is covered at exporter level by TestCodeBlockExport, including the multi-block grouping case.

def test_simple_code_block(self):
self.assertEqual(render_code_block("foo"), "```\nfoo\n```\n\n")

def test_content_not_escaped(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test name test_content_not_escaped is ambiguous - it could mean either 'verifying content is not escaped in code blocks' (intended) or 'content was previously escaped but should not be now'. Consider renaming to test_content_preserved_in_code_block or test_special_chars_not_escaped_in_code_block for clarity.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Keeping the name — within TestCodeBlockExport, test_content_not_escaped reads as intended alongside test_simple_code_block and test_fence_sized_to_content.

Comment on lines +16 to +17
def test_renders_verbatim(self):
self.assertEqual(DOM.render(inline([mark_safe("# "), "*x*"])), "# \\*x\\*")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test uses DOM.render(inline([mark_safe("# "), "x"])) expecting "# *x*". This correctly validates that a heading marker rendered verbatim via mark_safe allows the subsequent user text to be escaped at line start. The expected string "# \x\" contains the literal #, space, backslash, *, x, backslash, *. This is the correct expected output — the heading prefix is preserved and the asterisks are escaped. No issue.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for confirming — this test was strengthened in ae50109 following the earlier round's feedback.

Comment on lines +34 to +35
fence_char = fence[0]
return lambda props: DOM.create_element("code_block", {"fence": fence_char})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing fence[0] without validating that fence is non-empty will raise an IndexError if an empty string is passed. Although unlikely in practice (the default is "```"), adding a guard or asserting non-emptiness would improve robustness.

Suggestion:

Suggested change
fence_char = fence[0]
return lambda props: DOM.create_element("code_block", {"fence": fence_char})
if not fence:
raise ValueError("fence must be a non-empty string")
fence_char = fence[0]
return lambda props: DOM.create_element("code_block", {"fence": fence_char})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Not a defect: fence is typed as a literal set of the two supported fences in build_markdown_config, so type-checked callers cannot pass an empty string, and a runtime misuse fails loudly (IndexError) rather than silently. Per docs/SECURITY.md the exporter config is trusted developer code, not attacker input. Adding a guard + test to rephrase an already-loud failure is not worth it.

Comment on lines +184 to +185
if c.type == "mark_safe":
parts.append(c.attr["markup"] if c.attr else "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In flatten_text, the mark_safe branch accesses c.attr['markup'] without using .get() for safe access. If c.attr is None, the guard catches it. But if c.attr is a dict missing the 'markup' key (possible with direct create_element calls), KeyError is raised. Use .get("markup", "") for consistency with the code_block case.

Suggestion:

Suggested change
if c.type == "mark_safe":
parts.append(c.attr["markup"] if c.attr else "")
if c.type == "mark_safe":
parts.append(c.attr.get("markup", "") if c.attr else "")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Deliberate design choice: a mark_safe element without a markup key is a component contract violation, and a loud KeyError surfaces it immediately at render time. The suggested .get("markup", "") would silently drop structural syntax from the output, turning a developer bug into invisible output corruption. Loud beats silent here.

Comment thread tests/test_properties.py
Comment on lines +177 to +180
@given(escapable_text)
def test_escape_text_never_raises(self, text):
escape_text(text, at_line_start=True)
escape_text(text, at_line_start=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only verifies that escape_text doesn't raise an exception — it doesn't assert any property about the returned value. While this checks robustness, it's essentially a no-op assertion. Consider adding an assertion about the output, such as verifying the returned type is str, or rename to make the lack of output verification explicit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Not a defect — crash safety without output assertions is the established property-based test pattern in this project (see docs/contributing/test-strategy.md; the pre-existing test_markdown_engine_never_raises has the same shape). An isinstance(str) assertion could never fail, so it would add noise, not signal.

Comment thread tests/test_properties.py
Comment on lines +177 to +180
@given(escapable_text)
def test_escape_text_never_raises(self, text):
escape_text(text, at_line_start=True)
escape_text(text, at_line_start=False)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test only verifies that escape_text doesn't raise an exception — it doesn't assert any property about the returned value. While this checks robustness, it's essentially a no-op assertion. Consider adding an assertion about the output, such as verifying the returned type is str, or rename to make the lack of output verification explicit.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Duplicate of the comment above (same finding posted twice) — see the reply there. Not a defect.

@thibaudcolas

Copy link
Copy Markdown
Member Author

Triage of the automated review's summary finding (</> in escape_link_destination): CommonMark bare link destinations may contain < mid-URL — only a leading < switches to pointy-bracket destination form, and that case fails safe (the link degrades to literal text, no breakout). Autolinks cannot start inside ](…). Not a defect. Separately, & escaping in text has been dropped (d0d51f1), so the inconsistency the comment referenced no longer exists. All 14 inline findings across both review runs have been replied to individually: 1 addressed (ae50109), the rest are false positives or already-covered, with reasoning per comment.

Comment on lines +6 to 7
from draftjs_exporter.markdown.helpers import mark_safe
from draftjs_exporter.types import Block, Element, Props

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The list_item function now wraps structural Markdown syntax (indent, prefix, suffix) with mark_safe() to ensure proper escaping of user content in list items. The block_prefix=True for indent and prefix correctly indicates that these markers can start nested blocks, while suffix (typically just \n) does not need this flag. This is consistent with how mark_safe is used in other modules like blocks.py and styles.py. The change is well-tested with existing tests like test_list_item_marker_uses_mark_safe and test_line_start_in_list_item.

BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> ", block_prefix=True),
BLOCK_TYPES.CODE: {
"element": make_code_element(fence),
"element": make_code_element(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fence parameter was dead code in make_code_element() - the function body never used it. Removing it from the signature is correct and improves clarity.

Comment on lines +139 to +154
def escape_link_destination(url: str) -> str:
"""Escape a URL for use as an inline link destination inside ``](…)``.

Backslash-escapes backslashes and parentheses (which would otherwise
break out of the destination), and percent-encodes ASCII whitespace and
control characters, which inline destinations may not contain. URL
scheme validation is out of scope: it is the integrator's
responsibility (see ``docs/SECURITY.md``).

Parameters:
url: The URL to escape.

Returns:
The escaped destination.
"""
escaped = url.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The escape_link_destination function does not escape angle brackets (<, >). While ANYWHERE_ESCAPES in the same module includes < for mid-line text escaping, escape_link_destination only escapes backslashes and parentheses. URLs with angle brackets (e.g., https://example.com/<path>) are emitted verbatim into inline link destinations ](URL). In some markdown contexts or processors, unescaped angle brackets could have special meaning. Consider adding < and > to the character replacements in escape_link_destination.

Suggestion:

Suggested change
def escape_link_destination(url: str) -> str:
"""Escape a URL for use as an inline link destination inside ``](…)``.
Backslash-escapes backslashes and parentheses (which would otherwise
break out of the destination), and percent-encodes ASCII whitespace and
control characters, which inline destinations may not contain. URL
scheme validation is out of scope: it is the integrator's
responsibility (see ``docs/SECURITY.md``).
Parameters:
url: The URL to escape.
Returns:
The escaped destination.
"""
escaped = url.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
def escape_link_destination(url: str) -> str:
"""Escape a URL for use as an inline link destination inside ``](…)``.
Backslash-escapes backslashes, parentheses, and angle brackets
(which would otherwise break out of the destination or have special
meaning), and percent-encodes ASCII whitespace and control characters,
which inline destinations may not contain. URL scheme validation is out
of scope: it is the integrator's responsibility (see ``docs/SECURITY.md``).
Parameters:
url: The URL to escape.
Returns:
The escaped destination.
"""
escaped = (
url.replace("\\", "\\\\")
.replace("(", "\\(")
.replace(")", "\\)")
.replace("<", "\\<")
.replace(">", "\\>")
)

Comment on lines +114 to +136
def _escape_underscores(line: str) -> str:
"""Escape underscore runs that could form emphasis.

A run is left unescaped only when both adjacent characters are
alphanumeric, where CommonMark's flanking rules guarantee it can
neither open nor close emphasis.

Parameters:
line: The line to process, with other escapes already applied.

Returns:
The line with escapable underscore runs backslash-escaped.
"""

def replace(match: re.Match[str]) -> str:
run = match.group(0)
before = line[match.start() - 1] if match.start() > 0 else ""
after = line[match.end()] if match.end() < len(line) else ""
if before.isalnum() and after.isalnum():
return run
return "\\_" * len(run)

return UNDERSCORE_RUN.sub(replace, line)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _escape_underscores function uses a closure that captures line from the outer scope. While this works correctly for the current call pattern (single pass through UNDERSCORE_RUN.sub(replace, line)), the stale closure makes the code fragile and harder to reason about. Consider passing the full line as a parameter to the callback or using a different approach that doesn't rely on closure capture.

Suggestion:

Suggested change
def _escape_underscores(line: str) -> str:
"""Escape underscore runs that could form emphasis.
A run is left unescaped only when both adjacent characters are
alphanumeric, where CommonMark's flanking rules guarantee it can
neither open nor close emphasis.
Parameters:
line: The line to process, with other escapes already applied.
Returns:
The line with escapable underscore runs backslash-escaped.
"""
def replace(match: re.Match[str]) -> str:
run = match.group(0)
before = line[match.start() - 1] if match.start() > 0 else ""
after = line[match.end()] if match.end() < len(line) else ""
if before.isalnum() and after.isalnum():
return run
return "\\_" * len(run)
return UNDERSCORE_RUN.sub(replace, line)
def _escape_underscores(line: str) -> str:
"""Escape underscore runs that could form emphasis.
A run is left unescaped only when both adjacent characters are
alphanumeric, where CommonMark's flanking rules guarantee it can
neither open nor close emphasis.
Parameters:
line: The line to process, with other escapes already applied.
Returns:
The line with escapable underscore runs backslash-escaped.
"""
def replace(match: re.Match[str], line_text: str) -> str:
run = match.group(0)
before = line_text[match.start() - 1] if match.start() > 0 else ""
after = line_text[match.end()] if match.end() < len(line_text) else ""
if before.isalnum() and after.isalnum():
return run
return "\\_" * len(run)
return UNDERSCORE_RUN.sub(lambda m: replace(m, line), line)

Comment on lines +139 to +158
def escape_link_destination(url: str) -> str:
"""Escape a URL for use as an inline link destination inside ``](…)``.

Backslash-escapes backslashes and parentheses (which would otherwise
break out of the destination), and percent-encodes ASCII whitespace and
control characters, which inline destinations may not contain. URL
scheme validation is out of scope: it is the integrator's
responsibility (see ``docs/SECURITY.md``).

Parameters:
url: The URL to escape.

Returns:
The escaped destination.
"""
escaped = url.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage for escape_link_destination is missing key edge cases: (1) angle brackets in URLs (<, >) - currently not escaped, (2) null bytes and DEL character handling verification, (3) URLs containing both parentheses and whitespace/control characters to verify the mixed escaping/percent-encoding behavior is correct.

Suggestion:

Suggested change
def escape_link_destination(url: str) -> str:
"""Escape a URL for use as an inline link destination inside ``](…)``.
Backslash-escapes backslashes and parentheses (which would otherwise
break out of the destination), and percent-encodes ASCII whitespace and
control characters, which inline destinations may not contain. URL
scheme validation is out of scope: it is the integrator's
responsibility (see ``docs/SECURITY.md``).
Parameters:
url: The URL to escape.
Returns:
The escaped destination.
"""
escaped = url.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)
class TestEscapeLinkDestination(unittest.TestCase):
def test_plain_url_unchanged(self):
self.assertEqual(
escape_link_destination("https://example.com/a?b=1&c=2"),
"https://example.com/a?b=1&c=2",
)
def test_parentheses_escaped(self):
self.assertEqual(
escape_link_destination("https://example.com/a_(b)"),
"https://example.com/a_\\(b\\)",
)
def test_backslash_escaped(self):
self.assertEqual(escape_link_destination("a\\b"), "a\\\\b")
def test_space_percent_encoded(self):
self.assertEqual(escape_link_destination("a b"), "a%20b")
def test_control_char_percent_encoded(self):
self.assertEqual(escape_link_destination("a\nb"), "a%0Ab")
def test_non_ascii_unchanged(self):
self.assertEqual(escape_link_destination("/café"), "/café")
def test_angle_brackets_escaped(self):
# Angle brackets can have special meaning in markdown contexts.
self.assertEqual(
escape_link_destination("https://example.com/<path>"),
"https://example.com/\\<path\\>",
)
def test_null_byte_percent_encoded(self):
self.assertEqual(escape_link_destination("a\x00b"), "a%00b")
def test_del_percent_encoded(self):
self.assertEqual(escape_link_destination("a\x7fb"), "a%7Fb")
def test_mixed_parentheses_and_whitespace(self):
# Verify that parentheses are backslash-escaped and whitespace
# is percent-encoded, without interference.
self.assertEqual(
escape_link_destination("https://example.com/a (b) c"),
"https://example.com/a%20\\(b\\)%20c",
)

Comment on lines +8 to +9
def mark_safe(markup: str, block_prefix: bool = False) -> Element:
"""Create an element holding structural Markdown syntax.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Type coercion between Python bool and DOM string attribute: mark_safe(block_prefix=True) produces {"block_prefix": "true"} (string), while the Markdown engine checks c.attr.get("block_prefix") == "true" (string comparison). This is consistent throughout the codebase (blocks.py, lists.py, markdown/init.py all use block_prefix=True), but the implicit bool-to-string conversion could be clarified. Consider either documenting this behavior or adding explicit conversion in mark_safe using str(block_prefix).lower() for consistency.

Comment thread tests/test_properties.py
Comment on lines +182 to +185
@given(escapable_text)
def test_no_unescaped_angle_bracket(self, text):
escaped = escape_text(text, at_line_start=True)
self.assertIsNone(re.search(r"(?<!\\)<", escaped))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The negative lookbehind (?<!\\)< checks only whether the character immediately before < is a single backslash. It cannot distinguish an escaped < (preceded by an odd number of backslashes) from an un-escaped < preceded by an even number. For example, input "a\\\\<b" (three backslashes + <) would after str.translate become "a\\\\\\\\<b" — the < IS preceded by a backslash (satisfying (?<!\\)) but is NOT escaped (even count before it). The test silently passes on broken escaping. Consider rewriting the assertion to iterate through matches and count preceding backslashes, or use a different property (e.g. idempotence: escape_text(escape_text(s)) == escape_text(s)).

return block(
[
mark_safe("!["),
props.get("alt", ""),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alt text is passed as a plain string and will be escaped by escape_text(). While this prevents XSS-like issues with special characters, it may produce unexpected output when alt contains characters like *, _, [, ], `, etc. These characters should be rendered verbatim since they are valid in Markdown alt text. Consider wrapping alt with mark_safe() if the alt text is known to be pre-sanitized, or document that alt text will be escaped.

return inline(
[
mark_safe("["),
props["children"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The children text is passed as a plain string and will be escaped by escape_text(). Link text content can legitimately contain special Markdown characters like *, _, backticks, etc. that should render correctly. However, escaping may be intentional as a safety measure. Consider documenting whether link children should be escaped or rendered verbatim, and whether this is a security-conscious default.

return block(
[
mark_safe("!["),
props.get("alt", ""),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The alt text is passed as a plain string and will be escaped by escape_text(). While escaping prevents XSS-like issues, it may produce unexpected output when alt contains Markdown-valid characters like *, _, [, ], `, etc. These characters are safe within image alt text and should render verbatim. Consider wrapping alt with mark_safe() if the alt text is known to be pre-sanitized, or document that alt text will be escaped.

Comment on lines +36 to +44
return inline(
[
mark_safe("["),
props["children"],
mark_safe("]("),
link_destination(props["url"]),
mark_safe(")"),
]
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The children text is passed as a plain string and will be escaped by escape_text(). Link text content can legitimately contain Markdown characters like *, _, backticks, etc. that should render correctly. However, escaping may be intentional as a safety measure. Consider documenting whether link children should be escaped or rendered verbatim.

Comment on lines +155 to +158
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition char > " " uses string comparison rather than ordinal comparison. While this works correctly for ASCII characters, it is fragile and potentially confusing. For example, ord('\x80') > ord(' ') would encode 0x80, but "\x80" > " " in Python relies on lexicographic ordering that happens to work the same way here. Using ord(char) > 32 would be clearer and more explicit.

Suggestion:

Suggested change
return "".join(
char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
for char in escaped
)
return "".join(
char if ord(char) > 32 else f"%{ord(char):02X}"
for char in escaped
)

Comment thread tests/test_properties.py
Comment on lines +182 to +185
@given(escapable_text)
def test_no_unescaped_angle_bracket(self, text):
escaped = escape_text(text, at_line_start=True)
self.assertIsNone(re.search(r"(?<!\\)<", escaped))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test is testing the wrong escape mode. When at_line_start=False, angle brackets should still be escaped because < is in ANYWHERE_ESCAPES. The test should either: (1) check both modes, or (2) be renamed to clarify it only tests at_line_start=True. This is a medium-severity issue because the invariant should hold for both modes but isn't being verified.

Comment on lines +184 to +185
if c.type == "mark_safe":
parts.append(c.attr["markup"] if c.attr else "")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Potential KeyError if mark_safe element has attr dict without 'markup' key. While the mark_safe helper always includes 'markup', direct calls to create_element could pass incomplete attributes. Use .get() for defensive access.

Suggestion:

Suggested change
if c.type == "mark_safe":
parts.append(c.attr["markup"] if c.attr else "")
if c.type == "mark_safe":
parts.append(c.attr.get("markup", "") if c.attr else "")

Comment on lines +188 to +189
else:
parts.append(DOMMarkdown.flatten_text(c.children))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flatten_text method uses recursion for nested elements. Deeply nested structures in complex Draft.js content could cause RecursionError. Consider using an iterative approach with a stack.

Suggestion:

Suggested change
else:
parts.append(DOMMarkdown.flatten_text(c.children))
# Consider iterative approach for deep nesting

Comment on lines +168 to +169
@staticmethod
def flatten_text(children: list["str | Elt"]) -> str:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flatten_text method is used internally by code_span and code_block but has no dedicated unit tests. Adding direct tests would improve maintainability.

Suggestion:

Suggested change
@staticmethod
def flatten_text(children: list["str | Elt"]) -> str:
# Add unit tests for flatten_text directly

Comment on lines +154 to +159
if (
c.type == "mark_safe"
and c.attr
and c.attr.get("block_prefix") == "true"
):
at_line_start = True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing integration tests for block_prefix behavior with nested blocks (e.g., heading inside blockquote, list item with heading-like text). The render_children logic is correct but untested at integration level.

Suggestion:

Suggested change
if (
c.type == "mark_safe"
and c.attr
and c.attr.get("block_prefix") == "true"
):
at_line_start = True
# Add integration tests for nested block scenarios:
# - Heading inside blockquote: "> # x" should not escape
# - List item with heading-like text: "- # x" should escape

@thibaudcolas
thibaudcolas merged commit 54badf9 into main Aug 5, 2026
12 checks passed
@thibaudcolas
thibaudcolas deleted the markdown-escaping branch August 5, 2026 07:17
@thibaudcolas

Copy link
Copy Markdown
Member Author

📎 Design spec from the AI-assisted planning session, recovered from the reflog for reference. Slightly out of date with the final implementation (e.g. & escaping was later dropped, _ escaping made intraword-aware, | added at line start per review).


Markdown escaping design

Date: 2026-08-04
Status: approved design, pre-implementation

Context

The Markdown exporter (MARKDOWN_CONFIG / build_markdown_config, engine draftjs_exporter.engines.markdown.DOMMarkdown) currently emits block text and entity data completely verbatimDOMMarkdown.render_children concatenates strings with no escaping, and docs/markdown.md documents "No HTML escaping in text" as a known limitation.

Per the threat model in docs/SECURITY.md, ContentState (block text, inline style ranges, entity ranges, entity data) is attacker-controlled, and the rendered output crosses a trust boundary: it is typically stored and later rendered to HTML by a downstream Markdown renderer for other users. Unescaped Markdown output enables:

  • Stored XSS via inline HTML: <script> or <img onerror=…> in block text passes through most Markdown renderers as raw HTML.
  • Entity-reference injection: user-typed &copy; is decoded to © downstream (content spoofing).
  • Structure injection: text starting with # , - , > , 1. , ---, or a code fence alters the downstream document structure; text like [click](javascript:alert(1)) in prose creates attacker-chosen links.
  • Link destination breakout: a LINK entity URL containing ) breaks out of the ](…) destination.
  • Code delimiter breakout (pre-existing bug, fixed as part of this work): a code block containing ``` shatters its fence; inline code containing a backtick produces invalid output.

Goals

  • Context-aware, minimal-noise escaping of Markdown metacharacters in user text, following CommonMark backslash-escape rules.
  • No escaping inside code spans and code blocks; instead, delimiter sizing that makes breakout impossible.
  • Destination-specific escaping for link/image URLs.
  • Always on. No configuration option, no opt-out (Markdown support is documented as experimental).
  • Linear-time escaping (no catastrophic regex), per the DoS guidance in docs/SECURITY.md.

Non-goals

  • The Markdown importer (markdown_to_content_state) — not currently implemented; out of scope.
  • GFM-only constructs (autolinks, task lists): escaping targets CommonMark. With the exception of tables |, that is escaped anyway just in case.
  • Perfect blockquote/list continuation for text containing soft breaks (\n in block text): pre-existing limitation; escaping after embedded newlines still prevents heading/structure injection there.
  • Leading whitespace fidelity: 4+ spaces or a tab at the start of a line cannot be protected by backslash escapes (backslash only escapes ASCII punctuation) and may render as an indented code block downstream. This is a rendering-fidelity edge case, not a security issue. Documented as a known limitation.
  • URL scheme validation (javascript: etc.): remains the integrator's responsibility, as already documented in docs/SECURITY.md.

Architecture

Escaping lives in the Markdown engine at render time. html.py and the other engines are untouched.

The invariant

After this change, every tree rendered by DOMMarkdown obeys one rule:

A plain str child is always user text and is escaped by the engine. Anything else (structural markers, URL destinations, fences, \n separators) is explicitly wrapped by components and renders verbatim.

Over-escaping from a forgotten wrapper fails safe (cosmetic noise, not a vulnerability). The invariant is documented in draftjs_exporter/engines/markdown.py and draftjs_exporter/markdown/__init__.py docstrings.

New node kinds in the Markdown engine

Elt gains constructors/types alongside the existing from_html:

  • mark_safe(markup, block_prefix=False) — a structural literal rendered verbatim. block_prefix=True marks prefixes after which nested block structure can begin (list markers, list indentation, > ), used for line-start detection. Heading prefixes (# …) do not set it: content inside an ATX heading cannot start a nested block.
  • "code_span" node — children render unescaped; the engine computes the backtick delimiter (see below).
  • "code_block" node — children render unescaped; the engine computes the fence (see below). The configured fence character (` or ~) is passed via attr.

Line-start detection

render_children concatenates children into an accumulated output string. Before appending a plain str, it treats the string as starting a line when:

  1. the accumulated output is empty or ends with \n, or
  2. the previous sibling was a mark_safe node with block_prefix=True.

Every level of render returns a string, so the tail check works across element boundaries (e.g. after </sup> the tail is >, correctly mid-line). The escaper additionally applies line-start rules after every \n embedded within a text string (soft breaks).

Escaping rules

New module: draftjs_exporter/markdown/escape.py. All functions are linear-time, using str.translate / simple anchored regexes.

escape_text(text, at_line_start)

CommonMark allows a backslash before any ASCII punctuation; the escaped character renders literally.

Escaped anywhere (in order — backslash first):

Char Reason
\ escape character itself
` code span / code fence
* emphasis, thematic break
_ emphasis, thematic break
[ link/image open (also neutralizes ![ — no need to escape !)
] link close, link-text breakout
< inline HTML (XSS), autolinks
& entity/character reference decoding

Additionally escaped at line start (per the detection above, and after embedded \n):

Char Reason
# ATX heading
- bullet list, thematic break, setext underline
+ bullet list
> blockquote
= setext underline
| GFM-style tables
~ tilde code fence (and GFM strikethrough)
. / ) after leading digits (^(\d{1,9})([.)])) ordered list item — escape the delimiter, e.g. 1\. item

escape_link_destination(url)

Applied to LINK URLs and IMAGE src inside ](…):

  1. Backslash-escape \, (, ) (prevents destination breakout).
  2. Percent-encode ASCII whitespace and control characters (inline destinations may not contain them).

Scheme validation stays out of scope (integrator duty, already documented).

Code span delimiter sizing

Flatten the node's text content, then:

  • delimiter = ` repeated (longest backtick run in content + 1), minimum 1.
  • if the content starts or ends with a backtick, pad with one space after the opening / before the closing delimiter (CommonMark rule).

Code block fence sizing

  • fence = configured fence char (` or ~) repeated (longest run of that char in content + 1), minimum 3.

Flattening inside code contexts

Children of code_span / code_block nodes are flattened to their text content (nested elements contribute only their text, structure discarded) before delimiter sizing and verbatim emission.

Component changes (draftjs_exporter/markdown/)

All literal syntax strings move into mark_safe:

  • helpers.py: block() wraps its trailing "\n\n" in mark_safe.
  • blocks.py: prefixed_block wraps the prefix; > (blockquote) gets block_prefix=True, headings do not. List items (list_item): indent, marker prefix (- , 1. ) and suffixes (\n, \n\n) wrapped; marker prefix and indent get block_prefix=True.
  • styles.py: inline_style(mark) wraps both marks; the CODE style maps to a new component emitting a "code_span" node instead of backtick marks.
  • code.py: code_wrapper/code_element (and their make_* factories) are replaced by a single component emitting a "code_block" node carrying the configured fence char in attr.
  • entities.py: link[mark_safe("["), children, mark_safe("]("), link_destination(url), mark_safe(")")]; image similarly, with alt left as a plain string (text-escaped, ]-safe by the engine) and src through link_destination; horizontal_rule wraps its marker in mark_safe.
  • fallbacks.py: unchanged (children pass through).

escape_link_destination results are wrapped verbatim (a link_destination helper returns mark_safe(escape_link_destination(url))).

Security mapping (docs/SECURITY.md)

Threat Mitigation
Stored XSS via inline HTML in block text < and & escaped everywhere
Entity-reference spoofing & escaped
Structure injection (headings, lists, quotes, thematic breaks, fences) line-start escaping
Attacker-crafted links in prose [, ] escaped everywhere
Link destination breakout via ) escape_link_destination
Code fence/delimiter breakout engine-side delimiter sizing
ReDoS/DoS in escaping linear-time translate/anchored regex; covered by property-based tests

docs/SECURITY.md gains a short note that the Markdown engine escapes text and link destinations, and that scheme validation remains integrator duty.

Error handling

No new exceptions. Unknown node types keep the existing catch-all HTML rendering. Escaping never fails on malformed input (worst case: cosmetic noise) — matching the project's crash-safety property tests.

Testing

  • Unit (tests/markdown/test_escape.py, new): each metacharacter; backslash-first ordering; line-start vs. mid-line for #, -, >, =, ~, +, 1.; embedded \n; escape_link_destination (parens, backslash, spaces, control chars); code span sizing (backtick runs, leading/trailing backtick padding); fence sizing (``` and ~~~).
  • Engine (tests/engines/test_engines_markdown.py): mark_safe passthrough, block_prefix behavior, plain-string escaping, code_span/code_block unescaped content with sized delimiters, <sup>-style HTML elements unaffected (tags verbatim, children escaped).
  • Integration (tests/test_output.py): heading-like text, list-like text, blockquote-like text, 1. text, <script> in text, [x](javascript:…) in prose, link URL containing ), image alt containing ], code block containing fences, inline code containing backticks, soft break followed by #.
  • Snapshots (tests/test_exports.json): update existing Markdown cases whose output changes; add cases for the above.
  • Property-based (tests/test_properties.py, tests/strategies.py): metacharacter-heavy text strategies; assert crash safety and that output contains no unescaped line-start constructs derived from text.
  • Docs: docs/markdown.md — remove the "No HTML escaping in text" limitation, document escaping behavior and the leading-whitespace limitation; migration guide entry for the breaking output change; CHANGELOG entry.

Breaking change

Any Markdown output containing metacharacters in text changes. Called out in the migration guide and changelog; acceptable because Markdown support is documented as experimental.

@thibaudcolas

Copy link
Copy Markdown
Member Author

📎 Implementation plan from the AI-assisted session, recovered from the reflog for reference. Slightly out of date with the final state of the branch (see follow-up commits for the &/_ escaping changes and the line-start bypass fixes).


Markdown Escaping Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add context-aware CommonMark escaping of user-controlled text and link destinations to the Markdown exporter, and make code span/block delimiters breakout-proof.

Architecture: Escaping lives in the Markdown engine (draftjs_exporter/engines/markdown.py) at render time. One invariant governs the tree: a plain str child is always user text and gets escaped; structural syntax is wrapped in mark_safe elements by components. Line-start detection uses the accumulated render output (empty / ends with \n / previous sibling is mark_safe with block_prefix). Code spans and code blocks become typed code_span / code_block nodes whose content renders unescaped with content-sized delimiters. Spec: docs/superpowers/specs/2026-08-04-markdown-escaping-design.md.

Tech Stack: Python 3.10+, pytest/unittest, Hypothesis, ruff, mypy. Commands via just (see below).

Global Constraints

  • Follow AGENTS.md: Google-style docstrings on all public modules/classes/functions; type annotations on production code; __slots__ on core classes; sentence case everywhere.
  • Run tests with just test tests/path/to/test.py (strict mode). Run the full suite with just test before every commit.
  • Run just format and just lint before every commit; both must pass.
  • Commit messages: short, capitalized, imperative (e.g. Add Markdown text escaping primitives).
  • draftjs_exporter/markdown/escape.py MUST NOT import anything from draftjs_exporter (stdlib only) — it is imported by draftjs_exporter.engines.markdown at module level, and importing package modules from it creates circular imports.
  • Escaping is always on: no config option, no opt-out.
  • All escaping code must be linear-time (no backtracking regexes) per docs/SECURITY.md DoS guidance.

Task 1: escape_text primitive

Files:

  • Create: draftjs_exporter/markdown/escape.py
  • Test: tests/markdown/test_escape.py

Interfaces:

  • Produces: escape_text(text: str, at_line_start: bool = False) -> str — used by the engine (Task 3/6). Also produces module constants ANYWHERE_ESCAPES, LINE_START_ESCAPES, ORDERED_LIST_MARKER (internal).

  • Step 1: Write the failing tests

Create tests/markdown/test_escape.py:

"""Tests for Markdown escaping primitives."""

import unittest

from draftjs_exporter.markdown.escape import escape_text


class TestEscapeTextAnywhere(unittest.TestCase):
    def test_backslash_escaped_first(self):
        self.assertEqual(escape_text("a\\*b"), "a\\\\\\*b")

    def test_emphasis_chars(self):
        self.assertEqual(escape_text("*em* _em_"), "\\*em\\* \\_em\\_")

    def test_code_backtick(self):
        self.assertEqual(escape_text("a`b"), "a\\`b")

    def test_brackets(self):
        self.assertEqual(escape_text("[x](y)"), "\\[x\\](y)")

    def test_angle_bracket(self):
        self.assertEqual(escape_text("<script>"), "\\<script>")

    def test_ampersand(self):
        self.assertEqual(escape_text("&copy;"), "\\&copy;")

    def test_parentheses_not_escaped(self):
        self.assertEqual(escape_text("(hi)"), "(hi)")

    def test_greater_than_not_escaped_mid_line(self):
        self.assertEqual(escape_text("a > b"), "a > b")


class TestEscapeTextLineStart(unittest.TestCase):
    def test_hash_at_line_start(self):
        self.assertEqual(escape_text("# hi", at_line_start=True), "\\# hi")

    def test_hash_not_at_line_start(self):
        self.assertEqual(escape_text("# hi"), "# hi")

    def test_dash_plus(self):
        self.assertEqual(escape_text("- a", True), "\\- a")
        self.assertEqual(escape_text("+ a", True), "\\+ a")

    def test_blockquote_setext_tilde_pipe(self):
        self.assertEqual(escape_text("> a", True), "\\> a")
        self.assertEqual(escape_text("= a", True), "\\= a")
        self.assertEqual(escape_text("~ a", True), "\\~ a")
        self.assertEqual(escape_text("| a", True), "\\| a")

    def test_ordered_list_marker(self):
        self.assertEqual(escape_text("1. item", True), "1\\. item")
        self.assertEqual(escape_text("12) item", True), "12\\) item")

    def test_ordered_list_marker_ten_digits_not_escaped(self):
        self.assertEqual(escape_text("1234567890. x", True), "1234567890. x")

    def test_no_false_positive_after_anywhere_escape(self):
        # "*" at line start is escaped by the anywhere rule; the line-start
        # rule must not escape the resulting backslash again.
        self.assertEqual(escape_text("* a", True), "\\* a")

    def test_embedded_newline(self):
        self.assertEqual(escape_text("a\n- b"), "a\n\\- b")
        self.assertEqual(escape_text("a\n# b"), "a\n\\# b")

    def test_empty_string(self):
        self.assertEqual(escape_text("", at_line_start=True), "")


if __name__ == "__main__":
    unittest.main()
  • Step 2: Run tests to verify they fail

Run: just test tests/markdown/test_escape.py
Expected: FAIL with ModuleNotFoundError: No module named 'draftjs_exporter.markdown.escape'

  • Step 3: Implement escape.py

Create draftjs_exporter/markdown/escape.py:

"""Markdown escaping utilities for user-controlled text and link destinations.

This module must only depend on the standard library. It is imported by
``draftjs_exporter.engines.markdown`` at module level; importing other
``draftjs_exporter`` modules here would create circular imports.
"""

import re

# Characters escaped anywhere in text, via str.translate.
# Backslash first is handled naturally: translate is a single pass, and "\"
# maps to "\\" so user backslashes are escaped while inserted ones are not
# re-processed.
ANYWHERE_ESCAPES: dict[int, str] = str.maketrans(
    {
        "\\": "\\\\",
        "`": "\\`",
        "*": "\\*",
        "_": "\\_",
        "[": "\\[",
        "]": "\\]",
        "<": "\\<",
        "&": "\\&",
    }
)
"""Characters that must be escaped everywhere, including mid-line."""

# Characters escaped only at the start of a line, where they would open a
# block-level construct. None of these are in ANYWHERE_ESCAPES, so applying
# the line-start rule after the anywhere translate never double-escapes.
LINE_START_ESCAPES: dict[str, str] = {
    "#": "\\#",
    "-": "\\-",
    "+": "\\+",
    ">": "\\>",
    "=": "\\=",
    "|": "\\|",
    "~": "\\~",
}
"""Characters that must be escaped at the start of a line."""

# Ordered list markers: 1-9 digits followed by "." or ")" (CommonMark limit).
ORDERED_LIST_MARKER = re.compile(r"^(\\d{1,9})([.)])")
"""Matches an ordered list marker at the start of a line."""


def escape_text(text: str, at_line_start: bool = False) -> str:
    """Escape Markdown metacharacters in user-controlled text.

    Applies CommonMark backslash escapes. Line-start-sensitive characters
    are only escaped at the start of a line: when ``at_line_start`` is true
    for the first line, and after every embedded newline.

    Parameters:
        text: The user-controlled text to escape. May contain newlines.
        at_line_start: Whether the first character of ``text`` begins a line.

    Returns:
        The escaped text, safe to emit into Markdown output.
    """
    lines = text.split("\n")
    return "\n".join(
        _escape_line(line, at_line_start or i > 0) for i, line in enumerate(lines)
    )


def _escape_line(line: str, at_line_start: bool) -> str:
    """Escape a single line of text.

    Parameters:
        line: One line of user text, without newline characters.
        at_line_start: Whether the line is at the start of a rendered line.

    Returns:
        The escaped line.
    """
    line = line.translate(ANYWHERE_ESCAPES)
    if at_line_start:
        if line[:1] in LINE_START_ESCAPES:
            line = LINE_START_ESCAPES[line[0]] + line[1:]
        else:
            line = ORDERED_LIST_MARKER.sub(r"\1\\\2", line)
    return line
  • Step 4: Run tests to verify they pass

Run: just test tests/markdown/test_escape.py
Expected: PASS (all 17 tests)

  • Step 5: Commit
just format && just lint && just test
git add draftjs_exporter/markdown/escape.py tests/markdown/test_escape.py
git commit -m "Add Markdown text escaping primitives"

Task 2: Link destination escaping and code delimiter sizing

Files:

  • Modify: draftjs_exporter/markdown/escape.py
  • Test: tests/markdown/test_escape.py

Interfaces:

  • Consumes: nothing from Task 1 beyond the module existing.

  • Produces:

    • escape_link_destination(url: str) -> str — used by helpers.link_destination (Task 4).
    • longest_run(text: str, char: str) -> int — internal helper.
    • code_span_delimiters(content: str) -> tuple[str, str] — used by the engine (Task 3).
    • code_block_fence(content: str, fence_char: str) -> str — used by the engine (Task 3).
  • Step 1: Write the failing tests

Append to tests/markdown/test_escape.py:

from draftjs_exporter.markdown.escape import (
    code_block_fence,
    code_span_delimiters,
    escape_link_destination,
)


class TestEscapeLinkDestination(unittest.TestCase):
    def test_plain_url_unchanged(self):
        self.assertEqual(
            escape_link_destination("https://example.com/a?b=1&c=2"),
            "https://example.com/a?b=1&c=2",
        )

    def test_parentheses_escaped(self):
        self.assertEqual(
            escape_link_destination("https://example.com/a_(b)"),
            "https://example.com/a_\\(b\\)",
        )

    def test_backslash_escaped(self):
        self.assertEqual(escape_link_destination("a\\b"), "a\\\\b")

    def test_space_percent_encoded(self):
        self.assertEqual(escape_link_destination("a b"), "a%20b")

    def test_control_char_percent_encoded(self):
        self.assertEqual(escape_link_destination("a\nb"), "a%0Ab")

    def test_non_ascii_unchanged(self):
        self.assertEqual(escape_link_destination("/café"), "/café")


class TestCodeSpanDelimiters(unittest.TestCase):
    def test_plain_content(self):
        self.assertEqual(code_span_delimiters("foo"), ("`", "`"))

    def test_backtick_in_content(self):
        self.assertEqual(code_span_delimiters("a`b"), ("``", "``"))

    def test_two_backticks_in_content(self):
        self.assertEqual(code_span_delimiters("a``b"), ("```", "```"))

    def test_leading_backtick_padded(self):
        self.assertEqual(code_span_delimiters("`x"), ("`` ", " ``"))

    def test_trailing_backtick_padded(self):
        self.assertEqual(code_span_delimiters("x`"), ("`` ", " ``"))


class TestCodeBlockFence(unittest.TestCase):
    def test_plain_content(self):
        self.assertEqual(code_block_fence("foo\n", "`"), "```")

    def test_fence_run_in_content(self):
        self.assertEqual(code_block_fence("a```b\n", "`"), "````")

    def test_minimum_length_three(self):
        self.assertEqual(code_block_fence("a`b\n", "`"), "```")

    def test_tilde_fence(self):
        self.assertEqual(code_block_fence("a~~~b\n", "~"), "~~~~")
  • Step 2: Run tests to verify they fail

Run: just test tests/markdown/test_escape.py
Expected: FAIL with ImportError: cannot import name 'code_block_fence'

  • Step 3: Implement the new functions

Append to draftjs_exporter/markdown/escape.py:

def escape_link_destination(url: str) -> str:
    """Escape a URL for use as an inline link destination inside ``](…)``.

    Backslash-escapes backslashes and parentheses (which would otherwise
    break out of the destination), and percent-encodes ASCII whitespace and
    control characters, which inline destinations may not contain. URL
    scheme validation is out of scope: it is the integrator's
    responsibility (see ``docs/SECURITY.md``).

    Parameters:
        url: The URL to escape.

    Returns:
        The escaped destination.
    """
    escaped = url.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
    return "".join(
        char if char > " " and char != "\x7f" else f"%{ord(char):02X}"
        for char in escaped
    )


def longest_run(text: str, char: str) -> int:
    """Compute the length of the longest run of a character in a string.

    Parameters:
        text: The string to scan.
        char: The single character to count runs of.

    Returns:
        The longest run length, or ``0`` if the character is absent.
    """
    best = current = 0
    for c in text:
        current = current + 1 if c == char else 0
        best = max(best, current)
    return best


def code_span_delimiters(content: str) -> tuple[str, str]:
    """Compute opening and closing delimiters for an inline code span.

    The delimiter is one backtick longer than the longest backtick run in
    the content. When the content starts or ends with a backtick, a space
    is added inside the delimiters, per CommonMark.

    Parameters:
        content: The raw code span content.

    Returns:
        A tuple of ``(opening, closing)`` delimiter strings.
    """
    ticks = "`" * max(1, longest_run(content, "`") + 1)
    pad = " " if content.startswith("`") or content.endswith("`") else ""
    return ticks + pad, pad + ticks


def code_block_fence(content: str, fence_char: str) -> str:
    """Compute a code fence that cannot be broken by the content.

    The fence is one character longer than the longest run of the fence
    character in the content, and at least three characters long.

    Parameters:
        content: The raw code block content.
        fence_char: The fence character (backtick or tilde).

    Returns:
        The fence string.
    """
    return fence_char * max(3, longest_run(content, fence_char) + 1)
  • Step 4: Run tests to verify they pass

Run: just test tests/markdown/test_escape.py
Expected: PASS (all tests)

  • Step 5: Commit
just format && just lint && just test
git add draftjs_exporter/markdown/escape.py tests/markdown/test_escape.py
git commit -m "Add link destination escaping and code delimiter sizing"

Task 3: Engine support for mark_safe, code_span, and code_block nodes

Files:

  • Modify: draftjs_exporter/engines/markdown.py
  • Test: tests/engines/test_engines_markdown.py

Interfaces:

  • Consumes: code_span_delimiters, code_block_fence from draftjs_exporter/markdown/escape.py (Task 2).

  • Produces:

    • DOMMarkdown.flatten_text(children: list) -> str — used internally; later tasks rely on its behavior (mark_safe markup and escaped_html markup included verbatim, other elements recursed, plain strings concatenated).
    • Render support for element types "mark_safe" (renders elt.attr["markup"] verbatim), "code_span" and "code_block" (unescaped content, sized delimiters). Components (Tasks 4–5) create these via DOM.create_element.
    • Note: plain-string children are NOT escaped yet — that flip is Task 6.
  • Step 1: Write the failing tests

Append to tests/engines/test_engines_markdown.py (inside the existing test class or a new one — follow the file's existing structure):

class TestMarkSafe(unittest.TestCase):
    def test_rendered_verbatim(self):
        elt = M.create_tag("mark_safe", {"markup": "# ", "block_prefix": "true"})
        self.assertEqual(M.render(elt), "# ")

    def test_block_prefix_readable(self):
        elt = M.create_tag("mark_safe", {"markup": "- ", "block_prefix": "true"})
        self.assertEqual(elt.attr.get("block_prefix"), "true")


class TestCodeSpanNode(unittest.TestCase):
    def test_content_not_escaped(self):
        elt = M.create_tag("code_span")
        M.append_child(elt, "a*b`c")
        self.assertEqual(M.render(elt), "``a*b`c``")

    def test_plain_content(self):
        elt = M.create_tag("code_span")
        M.append_child(elt, "foo")
        self.assertEqual(M.render(elt), "`foo`")

    def test_leading_backtick_padded(self):
        elt = M.create_tag("code_span")
        M.append_child(elt, "`x")
        self.assertEqual(M.render(elt), "`` `x ``")


class TestCodeBlockNode(unittest.TestCase):
    def test_content_not_escaped(self):
        elt = M.create_tag("code_block", {"fence": "`"})
        M.append_child(elt, "# <script>\n")
        self.assertEqual(M.render(elt), "```\n# <script>\n```\n\n")

    def test_fence_sized_to_content(self):
        elt = M.create_tag("code_block", {"fence": "`"})
        M.append_child(elt, "a```b\n")
        self.assertEqual(M.render(elt), "````\na```b\n````\n\n")

    def test_tilde_fence(self):
        elt = M.create_tag("code_block", {"fence": "~"})
        M.append_child(elt, "x\n")
        self.assertEqual(M.render(elt), "~~~\nx\n~~~\n\n")

    def test_empty_content(self):
        elt = M.create_tag("code_block", {"fence": "`"})
        self.assertEqual(M.render(elt), "```\n```\n\n")

(Adjust M to the name the file uses for the engine — check the top of tests/engines/test_engines_markdown.py; it aliases DOMMarkdown.)

  • Step 2: Run tests to verify they fail

Run: just test tests/engines/test_engines_markdown.py
Expected: FAIL — mark_safe renders as <mark_safe ...></mark_safe> via the catch-all branch.

  • Step 3: Implement engine support

In draftjs_exporter/engines/markdown.py:

  1. Add the import after the existing from html import escape import:
from draftjs_exporter.markdown.escape import code_block_fence, code_span_delimiters
  1. Update the Elt class docstring (the old one says rendering does not escape text — still true until Task 6, but reword to describe the new node kinds):
class Elt:
    """A DOM element that the Markdown engine manipulates.

    Identical to the string engine's Elt, with extra Markdown-specific node
    types: ``mark_safe`` (structural syntax rendered verbatim), ``code_span``
    and ``code_block`` (content rendered unescaped with sized delimiters).
    """
  1. Update the DOMMarkdown class docstring:
class DOMMarkdown(DOMEngine):
    """String concatenation implementation of the DOM API for Markdown output.

    Invariant: a plain ``str`` child in the tree is always user-controlled
    text. Structural Markdown syntax must be wrapped in ``mark_safe``
    elements by components, or it will be escaped as text.
    """
  1. Add flatten_text and extend render with the new match cases. Replace the render method's match statement:
    @staticmethod
    def flatten_text(children: list["str | Elt"]) -> str:
        """Flatten children to raw text, discarding element structure.

        ``mark_safe`` and ``escaped_html`` markup is included verbatim;
        other elements contribute only their text content.

        Parameters:
            children: A list of strings and elements.

        Returns:
            The concatenated raw text.
        """
        parts = []
        for c in children:
            if isinstance(c, Elt):
                if c.type == "mark_safe":
                    parts.append(c.attr["markup"] if c.attr else "")
                elif c.markup:
                    parts.append(c.markup)
                else:
                    parts.append(DOMMarkdown.flatten_text(c.children))
            else:
                parts.append(c)
        return "".join(parts)

In render, replace the match statement:

        match type_:
            case "fragment":
                return children
            case "mark_safe":
                return elt.attr["markup"] if elt.attr else ""
            case "escaped_html":
                return elt.markup
            case "code_span":
                content = DOMMarkdown.flatten_text(elt.children)
                opening, closing = code_span_delimiters(content)
                return f"{opening}{content}{closing}"
            case "code_block":
                content = DOMMarkdown.flatten_text(elt.children)
                fence_char = (elt.attr or {}).get("fence", "`")
                fence = code_block_fence(content, fence_char)
                return f"{fence}\n{content}{fence}\n\n"
            case _ if type_ in VOID_ELEMENTS:
                return f"<{type_}{attr}/>"
            case _:
                return f"<{type_}{attr}>{children}</{type_}>"
  • Step 4: Run tests to verify they pass, and no regressions

Run: just test tests/engines/test_engines_markdown.py
Expected: PASS (old + new tests). Then run the full suite: just test — everything must stay green, because no component uses the new node types yet and plain strings are not escaped yet.

  • Step 5: Verify no circular import

Run: .venv/bin/python -c "import draftjs_exporter.engines.markdown; import draftjs_exporter; print('ok')"
Expected: ok (no ImportError).

  • Step 6: Commit
just format && just lint && just test
git add draftjs_exporter/engines/markdown.py tests/engines/test_engines_markdown.py
git commit -m "Add mark_safe, code_span and code_block nodes to Markdown engine"

Task 4: Wrap structural syntax in mark_safe across Markdown components

Files:

  • Modify: draftjs_exporter/markdown/helpers.py
  • Modify: draftjs_exporter/markdown/blocks.py
  • Modify: draftjs_exporter/markdown/lists.py
  • Modify: draftjs_exporter/markdown/styles.py
  • Modify: draftjs_exporter/markdown/entities.py
  • Modify: draftjs_exporter/markdown/__init__.py
  • Test: tests/markdown/test_helpers.py, tests/markdown/test_blocks.py, tests/markdown/test_lists.py, tests/markdown/test_styles.py, tests/markdown/test_entities.py

Interfaces:

  • Consumes: engine mark_safe support (Task 3), escape_link_destination (Task 2).
  • Produces:
    • mark_safe(markup: str, block_prefix: bool = False) -> Element in helpers.py — used by all Markdown components.
    • link_destination(url: str) -> Element in helpers.py — used by entities.py.
    • prefixed_block(prefix: str, block_prefix: bool = False) -> Component in blocks.py — new optional parameter.
    • Behavior contract for Task 6: no plain-string structural syntax remains in any Markdown component.

This task must not change any rendered output: mark_safe renders its markup verbatim, identical to the raw strings it replaces.

  • Step 1: Write the failing tests

Append to tests/markdown/test_helpers.py:

from draftjs_exporter.markdown.helpers import block, inline, link_destination, mark_safe


class TestMarkSafe(unittest.TestCase):
    def test_renders_verbatim(self):
        self.assertEqual(DOM.render(inline([mark_safe("# "), "x"])), "# x")

    def test_block_prefix_flag(self):
        elt = mark_safe("- ", block_prefix=True)
        self.assertEqual(elt.type, "mark_safe")
        self.assertEqual(elt.attr["block_prefix"], "true")

    def test_block_prefix_default_false(self):
        elt = mark_safe("# ")
        self.assertEqual(elt.attr["block_prefix"], "false")


class TestLinkDestination(unittest.TestCase):
    def test_renders_escaped_url(self):
        self.assertEqual(
            DOM.render(inline([link_destination("https://example.com/a(b)")])),
            "https://example.com/a\\(b\\)",
        )

Add structure tests to tests/markdown/test_blocks.py:

    def test_prefixed_block_uses_mark_safe(self):
        elt = prefixed_block("# ")({"children": "x"})
        self.assertEqual(elt.children[0].type, "mark_safe")

    def test_prefixed_block_block_prefix_flag(self):
        elt = prefixed_block("> ", block_prefix=True)({"children": "x"})
        self.assertEqual(elt.children[0].attr["block_prefix"], "true")

(Import prefixed_block from draftjs_exporter.markdown.blocks at the top of the file.)

Add to tests/markdown/test_lists.py:

    def test_list_item_marker_uses_mark_safe(self):
        item = ul({"block": {"key": "a", "type": "unordered-list-item", "depth": 0}, "blocks": [], "children": "x"})
        types = [c.type for c in item.children]
        self.assertEqual(types, ["mark_safe", "mark_safe", "mark_safe"])
        # children ("x") sits between the prefix and suffix mark_safe nodes
        self.assertEqual(item.children[2], "x")

(Adjust indexing to the actual children order: indent, prefix, children, suffix → types ["mark_safe", "mark_safe", "x", "mark_safe"]; write the test to match the implementation below.)

Add to tests/markdown/test_styles.py:

    def test_inline_style_uses_mark_safe(self):
        elt = inline_style("**")({"children": "x"})
        self.assertEqual(elt.children[0].type, "mark_safe")
        self.assertEqual(elt.children[0].attr["markup"], "**")

Add to tests/markdown/test_entities.py:

    def test_link_url_escaped(self):
        self.assertEqual(
            DOM.render(link({"url": "https://example.com/a(b)", "children": "x"})),
            "[x](https://example.com/a\\(b\\))",
        )

    def test_image_src_escaped(self):
        self.assertEqual(
            DOM.render(image({"src": "a b.png"})),
            "![](a%20b.png)\n\n",
        )
  • Step 2: Run tests to verify they fail

Run: just test tests/markdown/
Expected: FAIL — ImportError: cannot import name 'mark_safe' from 'draftjs_exporter.markdown.helpers'

  • Step 3: Implement the component changes

Rewrite draftjs_exporter/markdown/helpers.py:

"""Low-level helper components for inline and block Markdown fragments."""

from draftjs_exporter.dom import DOM
from draftjs_exporter.markdown.escape import escape_link_destination
from draftjs_exporter.types import Element


def mark_safe(markup: str, block_prefix: bool = False) -> Element:
    """Create an element holding structural Markdown syntax.

    The Markdown engine renders ``mark_safe`` markup verbatim, without the
    escaping applied to plain text children. Every piece of structural
    syntax emitted by Markdown components must be wrapped with this helper.

    Parameters:
        markup: The structural Markdown to emit (markers, fences, spacing).
        block_prefix: Whether text following this markup can start a nested
            block. True for list markers, list indentation, and blockquote
            ``"> "`` prefixes; false for heading prefixes and inline marks.

    Returns:
        An element the Markdown engine renders without escaping.
    """
    return DOM.create_element(
        "mark_safe", {"markup": markup, "block_prefix": block_prefix}
    )


def link_destination(url: str) -> Element:
    """Create an element holding a link or image URL for ``](…)``.

    Parameters:
        url: The URL to escape and emit.

    Returns:
        An element rendering the escaped URL without further escaping.
    """
    return mark_safe(escape_link_destination(url))


def inline(children: list[str | Element]) -> Element:
    """Create an inline fragment for inline formatting such as bold, links, and code.

    Parameters:
        children: The strings and elements to group inline.

    Returns:
        A fragment containing the children without extra whitespace.
    """
    return DOM.create_element("fragment", {}, children)


def block(children: list[str | Element]) -> Element:
    """Create a block fragment followed by an empty line.

    Parameters:
        children: The strings and elements that form the block content.

    Returns:
        A fragment containing the children and a trailing blank line.
    """
    return DOM.create_element("fragment", {}, children + [mark_safe("\n\n")])

In draftjs_exporter/markdown/blocks.py, replace prefixed_block:

from draftjs_exporter.markdown.helpers import block, inline, mark_safe


def prefixed_block(prefix: str, block_prefix: bool = False) -> Component:
    """Create a block component that prefixes its children with the given string.

    Parameters:
        prefix: The literal prefix to insert before the block's children.
        block_prefix: Whether children can start a nested block after the
            prefix (true for blockquotes, false for headings).

    Returns:
        A component that renders the prefixed block.
    """
    return lambda props: block(
        [mark_safe(prefix, block_prefix=block_prefix), props["children"]]
    )

In draftjs_exporter/markdown/lists.py, update list_item (add the import from draftjs_exporter.markdown.helpers import mark_safe):

def list_item(prefix: str, props: Props) -> Element:
    """Render a single Markdown list item with indentation and suffix.

    Parameters:
        prefix: The list marker prefix, including any trailing space.
        props: Render properties including the current block and all blocks.

    Returns:
        A fragment containing the indented prefix, children, and trailing newline.
    """
    indent = "  " * props["block"]["depth"]
    suffix = get_li_suffix(props)

    return DOM.create_element(
        "fragment",
        {},
        [
            mark_safe(indent, block_prefix=True),
            mark_safe(prefix, block_prefix=True),
            props["children"],
            mark_safe(suffix),
        ],
    )

In draftjs_exporter/markdown/styles.py:

"""Markdown inline style components for bold, italic, code, and strikethrough."""

from draftjs_exporter.markdown.helpers import inline, mark_safe
from draftjs_exporter.types import Component


def inline_style(mark: str) -> Component:
    """Create an inline style component that wraps text in the given mark.

    Parameters:
        mark: The delimiter to place before and after the styled text.

    Returns:
        A component that renders the marked inline style.
    """
    return lambda props: inline([mark_safe(mark), props["children"], mark_safe(mark)])

In draftjs_exporter/markdown/entities.py:

"""Markdown entity decorators for images, links, and horizontal rules."""

from draftjs_exporter.markdown.helpers import block, inline, link_destination, mark_safe
from draftjs_exporter.types import Component, Element, Props


def image(props: Props) -> Element:
    """Render an image as a Markdown image reference.

    Parameters:
        props: Render properties including ``alt`` and ``src``.

    Returns:
        A block-level image element.
    """
    return block(
        [
            mark_safe("!["),
            props.get("alt", ""),
            mark_safe("]("),
            link_destination(props["src"]),
            mark_safe(")"),
        ]
    )


def link(props: Props) -> Element:
    """Render a link as a Markdown inline reference.

    Parameters:
        props: Render properties including ``children`` and ``url``.

    Returns:
        An inline link element.
    """
    return inline(
        [
            mark_safe("["),
            props["children"],
            mark_safe("]("),
            link_destination(props["url"]),
            mark_safe(")"),
        ]
    )


def make_horizontal_rule(marker: str) -> Component:
    """Create a horizontal rule component using the given marker.

    Parameters:
        marker: The literal marker characters to render.

    Returns:
        A component that renders a horizontal rule.
    """
    return lambda props: block([mark_safe(marker)])

In draftjs_exporter/markdown/__init__.py, change the blockquote entry in BOTH the module-level BLOCK_MAP and build_markdown_config:

BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> ", block_prefix=True),
  • Step 4: Run tests to verify they pass, and no output changed

Run: just test tests/markdown/ tests/engines/
Expected: PASS. Then run the FULL suite: just test.
Expected: PASS with zero changes to tests/test_exports.json — rendered output must be identical before and after this task (mark_safe renders verbatim). Any snapshot failure means a marker was missed or behavior changed; investigate, do not update snapshots in this task. Exception: the two new entity tests assert changed URL-escaping behavior (test_link_url_escaped, test_image_src_escaped) — those are new tests. Existing snapshot entries with URLs (?a=1&b=2, placekitten, etc.) must be unaffected because none contain (, ), spaces, or control characters.

  • Step 5: Commit
just format && just lint && just test
git add draftjs_exporter/markdown/ tests/markdown/
git commit -m "Wrap Markdown structural syntax in mark_safe elements"

Task 5: Code blocks and inline code as typed nodes

Files:

  • Modify: draftjs_exporter/markdown/code.py
  • Modify: draftjs_exporter/markdown/styles.py
  • Modify: draftjs_exporter/markdown/__init__.py
  • Test: tests/markdown/test_code.py, tests/markdown/test_styles.py

Interfaces:

  • Consumes: engine code_span / code_block support (Task 3), mark_safe (Task 4).

  • Produces:

    • make_code_element() -> Component — signature change: no longer takes fence. Renders one line of code block content (children + newline).
    • make_code_wrapper(fence: str) -> Component — returns a code_block node with {"fence": fence[0]}.
    • code_element, code_wrapper module-level instances (unchanged names).
    • code_span(props: Props) -> Element in styles.py — mapped to INLINE_STYLES.CODE.
  • Step 1: Write the failing tests

Rewrite tests/markdown/test_code.py:

import unittest

from draftjs_exporter.dom import DOM
from draftjs_exporter.html import HTML
from draftjs_exporter.markdown import CONFIG as MARKDOWN_CONFIG
from draftjs_exporter.markdown.code import (
    code_element,
    code_wrapper,
    make_code_element,
    make_code_wrapper,
)


def render_code_block(text: str) -> str:
    exporter = HTML(MARKDOWN_CONFIG)
    return exporter.render(
        {
            "entityMap": {},
            "blocks": [
                {
                    "key": "a",
                    "text": text,
                    "type": "code-block",
                    "depth": 0,
                    "inlineStyleRanges": [],
                    "entityRanges": [],
                }
            ],
        }
    )


class TestCodeElement(unittest.TestCase):
    def test_renders_line_with_newline(self):
        self.assertEqual(
            DOM.render(code_element({"block": {}, "children": "test"})),
            "test\n",
        )


class TestMakeCodeElement(unittest.TestCase):
    def test_renders_line_with_newline(self):
        self.assertEqual(
            DOM.render(make_code_element()({"block": {}, "children": "test"})),
            "test\n",
        )


class TestCodeWrapper(unittest.TestCase):
    def test_renders_code_block_node(self):
        elt = code_wrapper({"block": {}})
        self.assertEqual(elt.type, "code_block")
        self.assertEqual(elt.attr["fence"], "`")


class TestMakeCodeWrapper(unittest.TestCase):
    def test_tilde_fence(self):
        elt = make_code_wrapper("~~~")({"block": {}})
        self.assertEqual(elt.attr["fence"], "~")


class TestCodeBlockExport(unittest.TestCase):
    def test_simple_code_block(self):
        self.assertEqual(render_code_block("foo"), "```\nfoo\n```\n\n")

    def test_content_not_escaped(self):
        self.assertEqual(render_code_block("# <x> & [y]"), "```\n# <x> & [y]\n```\n\n")

    def test_fence_sized_to_content(self):
        self.assertEqual(render_code_block("a```b"), "````\na```b\n````\n\n")

Append to tests/markdown/test_styles.py:

from draftjs_exporter.markdown.styles import code_span


class TestCodeSpan(unittest.TestCase):
    def test_renders_code_span_node(self):
        elt = code_span({"children": "a*b"})
        self.assertEqual(elt.type, "code_span")
        self.assertEqual(DOM.render(elt), "`a*b`")

    def test_backtick_content_sized(self):
        self.assertEqual(DOM.render(code_span({"children": "a`b"})), "``a`b``")
  • Step 2: Run tests to verify they fail

Run: just test tests/markdown/test_code.py tests/markdown/test_styles.py
Expected: FAIL — make_code_element() takes 1 argument, code_span missing.

  • Step 3: Implement

Rewrite draftjs_exporter/markdown/code.py:

"""Markdown code block components and fenced code builders."""

from draftjs_exporter.dom import DOM
from draftjs_exporter.markdown.helpers import mark_safe
from draftjs_exporter.types import Component, Element, Props


def make_code_element() -> Component:
    """Create a code-block line component.

    Each Draft.js code block contributes one line to the shared code_block
    node created by the wrapper.

    Returns:
        A component that renders one line of code block content.
    """

    def element(props: Props) -> Element:
        return DOM.create_element("fragment", {}, [props["children"], mark_safe("\n")])

    return element


def make_code_wrapper(fence: str) -> Component:
    """Create a code-block wrapper component using the given fence.

    Parameters:
        fence: The fence delimiter; its first character sizes the rendered
            fence (`` ``` `` or ``~~~``).

    Returns:
        A component that creates the code_block node holding all lines.
    """
    fence_char = fence[0]
    return lambda props: DOM.create_element("code_block", {"fence": fence_char})


code_element: Component = make_code_element()
code_wrapper: Component = make_code_wrapper("```")

Add to draftjs_exporter/markdown/styles.py (with Element, Props added to the types import, and DOM imported):

def code_span(props: Props) -> Element:
    """Render inline code as a code span.

    The engine emits the content unescaped, with delimiters sized to the
    content's backtick runs.

    Parameters:
        props: Render properties including ``children``.

    Returns:
        A code_span element.
    """
    return DOM.create_element("code_span", {}, props["children"])

In draftjs_exporter/markdown/__init__.py:

  • Replace both occurrences of INLINE_STYLES.CODE: inline_style("")withINLINE_STYLES.CODE: code_span(importcode_spanfromdraftjs_exporter.markdown.styles`).

  • In build_markdown_config, replace make_code_element(fence) with make_code_element(). Remove the now-unused fence variable only if nothing else uses it — make_code_wrapper(fence) still does, so keep it.

  • Step 4: Run tests to verify they pass, and no regressions

Run: just test tests/markdown/
Expected: PASS. Then just test for the full suite.
Expected: PASS. Existing snapshot code block outputs (e.g. "Big content export" ends with a fenced block) must be unchanged: a code block with no backtick runs still renders with a 3-char fence.

  • Step 5: Commit
just format && just lint && just test
git add draftjs_exporter/markdown/ tests/markdown/
git commit -m "Render Markdown code spans and blocks as typed nodes"

Task 6: Enable text escaping in the engine (the flip)

Files:

  • Modify: draftjs_exporter/engines/markdown.py
  • Test: tests/engines/test_engines_markdown.py

Interfaces:

  • Consumes: escape_text (Task 1), mark_safe wrapping everywhere (Tasks 4–5).

  • Produces: DOMMarkdown.render_children escapes plain str children. This is the behavior flip the whole plan builds toward.

  • Step 1: Write the failing tests

In tests/engines/test_engines_markdown.py, UPDATE the existing test that asserts no escaping (around line 73, currently asserting M.render_children(["<strong>not escaped</strong>"]) returns the input). Replace it with:

    def test_render_children_escapes_text(self):
        """Plain string children are user text and are escaped."""
        self.assertEqual(
            M.render_children(["<strong>not escaped</strong>"]),
            "\\<strong>not escaped</strong>",
        )

Append a new test class:

class TestRenderChildrenEscaping(unittest.TestCase):
    def test_metacharacters_escaped(self):
        self.assertEqual(M.render_children(["*a* _b_ [c] `d` &e"]), "\\*a\\* \\_b\\_ \\[c\\] \\`d\\` \\&e")

    def test_first_string_is_line_start(self):
        self.assertEqual(M.render_children(["# not a heading"]), "\\# not a heading")

    def test_line_start_after_newline(self):
        frag = M.create_tag("fragment")
        M.append_child(frag, M.create_tag("mark_safe", {"markup": "intro\n"}))
        M.append_child(frag, "# b")
        self.assertEqual(M.render(frag), "intro\n\\# b")

    def test_no_line_start_mid_line(self):
        frag = M.create_tag("fragment")
        M.append_child(frag, M.create_tag("mark_safe", {"markup": "# "}))
        M.append_child(frag, "#tag")
        self.assertEqual(M.render(frag), "# #tag")

    def test_line_start_after_block_prefix(self):
        frag = M.create_tag("fragment")
        M.append_child(frag, M.create_tag("mark_safe", {"markup": "- ", "block_prefix": "true"}))
        M.append_child(frag, "# not a heading")
        self.assertEqual(M.render(frag), "- \\# not a heading")

    def test_no_line_start_after_plain_mark_safe(self):
        frag = M.create_tag("fragment")
        M.append_child(frag, M.create_tag("mark_safe", {"markup": "**"}))
        M.append_child(frag, "#bold")
        self.assertEqual(M.render(frag), "**#bold")

    def test_mark_safe_verbatim_among_text(self):
        frag = M.create_tag("fragment")
        M.append_child(frag, "a")
        M.append_child(frag, M.create_tag("mark_safe", {"markup": "**"}))
        M.append_child(frag, "b")
        self.assertEqual(M.render(frag), "a**b")

    def test_embedded_newline_in_text(self):
        self.assertEqual(M.render_children(["a\n- b"]), "a\n\\- b")

    def test_html_element_children_escaped(self):
        elt = M.create_tag("sup")
        M.append_child(elt, "a<b")
        self.assertEqual(M.render(elt), "<sup>a\\<b</sup>")

    def test_line_start_tracking_across_element(self):
        frag = M.create_tag("fragment")
        M.append_child(frag, M.create_tag("sup"))
        M.append_child(frag, "# x")
        # <sup></sup> does not end with a newline: mid-line, no escape.
        self.assertEqual(M.render(frag), "<sup></sup># x")
  • Step 2: Run tests to verify they fail

Run: just test tests/engines/test_engines_markdown.py
Expected: FAIL — strings render unescaped.

  • Step 3: Implement the flip

In draftjs_exporter/engines/markdown.py:

  1. Extend the import from Task 3:
from draftjs_exporter.markdown.escape import (
    code_block_fence,
    code_span_delimiters,
    escape_text,
)
  1. Update the Elt class docstring to remove "rendering does not escape text":
class Elt:
    """A DOM element that the Markdown engine manipulates.

    Identical to the string engine's Elt, with extra Markdown-specific node
    types: ``mark_safe`` (structural syntax rendered verbatim), ``code_span``
    and ``code_block`` (content rendered unescaped with sized delimiters).
    """
  1. Replace render_children:
    @staticmethod
    def render_children(children: list[HTML | Elt]) -> HTML:
        """Render a list of children, escaping plain strings as user text.

        A plain ``str`` child is always user-controlled text and is escaped.
        Structural syntax wrapped in ``mark_safe`` elements renders verbatim.
        Line-start-sensitive characters are escaped when a string begins a
        line: at the start of the children list, when the output so far ends
        with a newline, or right after a ``mark_safe`` element created with
        ``block_prefix``.

        Parameters:
            children: A list of strings and elements to render.

        Returns:
            The rendered children.
        """
        out: list[str] = []
        at_line_start = True
        for c in children:
            if isinstance(c, Elt):
                rendered = DOMMarkdown.render(c)
                out.append(rendered)
                if (
                    c.type == "mark_safe"
                    and c.attr
                    and c.attr.get("block_prefix") == "true"
                ):
                    at_line_start = True
                elif rendered:
                    at_line_start = rendered.endswith("\n")
            else:
                out.append(escape_text(c, at_line_start))
                if c:
                    at_line_start = c.endswith("\n")
        return "".join(out)
  • Step 4: Run engine and markdown tests

Run: just test tests/engines/test_engines_markdown.py tests/markdown/
Expected: PASS.

  • Step 5: Run the full suite and triage failures

Run: just test
Expected: FAILURES in tests/test_exports.py — snapshot entries whose Markdown output now gains backslash escapes. Known-affected entries (verify, don't assume):

  • HTML entities escaping: link text &http://www.example.com/?a=1\&b=2 (destination unchanged).
  • Big content export: int & booleanint \& boolean; - #hashtag support- \#hashtag support; possibly others.
  • Multiple decorators: verify whether #world mid-line stays unescaped (it should).

Do NOT fix them in this task beyond confirming every diff consists only of added backslash escapes in user text (Task 7 updates the JSON). Any diff that eats/emits structure means a missed mark_safe wrapper — fix the component instead.

All other test files must PASS.

  • Step 6: Commit
just format && just lint
git add draftjs_exporter/engines/markdown.py tests/engines/test_engines_markdown.py
git commit -m "Escape user text in Markdown engine output"

(Commit without the full suite green is acceptable here only because Task 7 immediately fixes the known snapshot diffs; if you prefer, combine Tasks 6+7 into one commit.)


Task 7: Update test_exports.json snapshots

Files:

  • Modify: tests/test_exports.json
  • Test: tests/test_exports.py

Interfaces:

  • Consumes: Task 6 behavior.

  • Produces: green snapshot suite.

  • Step 1: Identify every changed Markdown snapshot

Run: just test tests/test_exports.py 2>&1 | grep -E "FAIL|Error" (or run and read assertion diffs).
For each failure, inspect the diff carefully. Every diff must be purely added \ characters in user text. Known changes:

  • HTML entities escaping"[http://www.example.com/?a=1\\&b=2](http://www.example.com/?a=1&b=2)\n\n" (JSON-escaped backslash: \\&).

  • Big content exportint \& boolean, - \#hashtag support, and any other text metacharacters (review line by line).

  • Step 2: Update the JSON

Edit tests/test_exports.json, changing only the "markdown" output strings of the affected entries. Remember JSON string escaping: a literal \ in the output is written \\.

  • Step 3: Run the snapshot tests

Run: just test tests/test_exports.py
Expected: PASS.

  • Step 4: Run the full suite

Run: just test
Expected: PASS — everything green again.

  • Step 5: Commit
just format && just lint && just test
git add tests/test_exports.json
git commit -m "Update Markdown export snapshots for text escaping"

Task 8: Exporter-level escaping integration tests

Files:

  • Create: tests/markdown/test_escaping.py

Interfaces:

  • Consumes: all previous tasks.

  • Produces: regression coverage for the escaping behavior at the HTML(MARKDOWN_CONFIG) level.

  • Step 1: Write the tests

Create tests/markdown/test_escaping.py:

"""Integration tests for Markdown escaping at the exporter level."""

import unittest

from draftjs_exporter.html import HTML
from draftjs_exporter.markdown import CONFIG as MARKDOWN_CONFIG
from draftjs_exporter.types import ContentState


def content_state(
    text: str,
    type_: str = "unstyled",
    depth: int = 0,
    inline_style_ranges: list | None = None,
    entity_ranges: list | None = None,
    entity_map: dict | None = None,
) -> ContentState:
    """Build a single-block ContentState for testing."""
    return {
        "entityMap": entity_map or {},
        "blocks": [
            {
                "key": "a",
                "text": text,
                "type": type_,
                "depth": depth,
                "inlineStyleRanges": inline_style_ranges or [],
                "entityRanges": entity_ranges or [],
            }
        ],
    }


class TestMarkdownEscaping(unittest.TestCase):
    def setUp(self):
        self.exporter = HTML(MARKDOWN_CONFIG)

    def render(self, *args, **kwargs) -> str:
        return self.exporter.render(content_state(*args, **kwargs))

    def test_heading_like_text(self):
        self.assertEqual(self.render("# Not a heading"), "\\# Not a heading\n\n")

    def test_unordered_list_like_text(self):
        self.assertEqual(self.render("- not an item"), "\\- not an item\n\n")

    def test_ordered_list_like_text(self):
        self.assertEqual(self.render("1. not an item"), "1\\. not an item\n\n")

    def test_blockquote_like_text(self):
        self.assertEqual(self.render("> not a quote"), "\\> not a quote\n\n")

    def test_thematic_break_like_text(self):
        self.assertEqual(self.render("---"), "\\---\n\n")

    def test_inline_html(self):
        self.assertEqual(
            self.render("<script>alert(1)</script>"),
            "\\<script>alert(1)\\</script>\n\n",
        )

    def test_entity_reference(self):
        self.assertEqual(self.render("&copy;"), "\\&copy;\n\n")

    def test_link_syntax_in_prose(self):
        self.assertEqual(
            self.render("[click](javascript:alert(1))"),
            "\\[click\\](javascript:alert(1))\n\n",
        )

    def test_mid_line_hash_not_escaped(self):
        self.assertEqual(self.render("a # b"), "a # b\n\n")

    def test_soft_break_line_start(self):
        self.assertEqual(self.render("a\n# b"), "a\n\\# b\n\n")

    def test_line_start_in_list_item(self):
        self.assertEqual(
            self.render("# x", type_="unordered-list-item"),
            "- \\# x\n\n",
        )

    def test_line_start_in_blockquote(self):
        self.assertEqual(
            self.render("# x", type_="blockquote"),
            "> \\# x\n\n",
        )

    def test_hash_in_heading_not_escaped(self):
        # ATX heading content cannot start a nested block: no escape needed.
        self.assertEqual(
            self.render("#tag", type_="header-one"),
            "# #tag\n\n",
        )

    def test_code_block_not_escaped(self):
        self.assertEqual(
            self.render("# <x> & [y]", type_="code-block"),
            "```\n# <x> & [y]\n```\n\n",
        )

    def test_code_block_fence_breakout_impossible(self):
        self.assertEqual(
            self.render("```", type_="code-block"),
            "````\n```\n````\n\n",
        )

    def test_inline_code_not_escaped(self):
        self.assertEqual(
            self.render(
                "x a*b y",
                inline_style_ranges=[{"offset": 2, "length": 3, "style": "CODE"}],
            ),
            "x `a*b` y\n\n",
        )

    def test_inline_code_backtick_sizing(self):
        self.assertEqual(
            self.render(
                "x a`b y",
                inline_style_ranges=[{"offset": 2, "length": 3, "style": "CODE"}],
            ),
            "x ``a`b`` y\n\n",
        )

    def test_link_url_parenthesis_escaped(self):
        self.assertEqual(
            self.render(
                "click",
                entity_ranges=[{"offset": 0, "length": 5, "key": 0}],
                entity_map={
                    "0": {
                        "type": "LINK",
                        "mutability": "MUTABLE",
                        "data": {"url": "https://example.com/a(b)"},
                    }
                },
            ),
            "[click](https://example.com/a\\(b\\))\n\n",
        )

    def test_link_text_escaped(self):
        self.assertEqual(
            self.render(
                "a&b",
                entity_ranges=[{"offset": 0, "length": 3, "key": 0}],
                entity_map={
                    "0": {
                        "type": "LINK",
                        "mutability": "MUTABLE",
                        "data": {"url": "https://example.com/"},
                    }
                },
            ),
            "[a\\&b](https://example.com/)\n\n",
        )

    def test_image_alt_bracket_escaped(self):
        self.assertEqual(
            self.render(
                " ",
                entity_ranges=[{"offset": 0, "length": 1, "key": 0}],
                entity_map={
                    "0": {
                        "type": "IMAGE",
                        "mutability": "IMMUTABLE",
                        "data": {"src": "x.png", "alt": "a]b"},
                    }
                },
            ),
            "![a\\]b](x.png)\n\n",
        )


if __name__ == "__main__":
    unittest.main()
  • Step 2: Run the tests

Run: just test tests/markdown/test_escaping.py
Expected: PASS on the first run if Tasks 1–7 are correct. For any failure, diff actual vs expected: if the actual output is more escaped than expected, check whether the expectation or the line-start detection is wrong; fix whichever is incorrect. Do not weaken tests to match buggy output.

Note: test_image_alt_bracket_escaped renders the image via the entity — if the exporter's entity handling requires the entity range to cover actual text, the single-space text is intentional.

  • Step 3: Run the full suite and commit
just format && just lint && just test
git add tests/markdown/test_escaping.py
git commit -m "Add Markdown escaping integration tests"

Task 9: Property-based tests for escaping

Files:

  • Modify: tests/test_properties.py

Interfaces:

  • Consumes: escape_text (Task 1).

  • Produces: Hypothesis invariants for escaping.

  • Step 1: Write the tests

Append to tests/test_properties.py (check the file's existing imports; add import re, from hypothesis import strategies as st if missing, and from draftjs_exporter.markdown.escape import escape_text):

class TestMarkdownEscapingInvariants(unittest.TestCase):
    """Invariants of Markdown escaping of user-controlled text."""

    @given(st.text())
    def test_escape_text_never_raises(self, text):
        escape_text(text, at_line_start=True)
        escape_text(text, at_line_start=False)

    @given(st.text())
    def test_no_unescaped_angle_bracket_or_ampersand(self, text):
        escaped = escape_text(text, at_line_start=True)
        self.assertIsNone(re.search(r"(?<!\\)<", escaped))
        self.assertIsNone(re.search(r"(?<!\\)&", escaped))

    @given(st.sampled_from("#-+>=|~").map(lambda c: c + "x"))
    def test_line_start_char_always_escaped(self, text):
        self.assertTrue(escape_text(text, at_line_start=True).startswith("\\"))

    @given(st.from_regex(r"\d{1,9}[.)]x", fullmatch=True))
    def test_ordered_list_marker_always_escaped(self, text):
        m = re.match(r"(\d{1,9})([.)])", text)
        assert m is not None
        escaped = escape_text(text, at_line_start=True)
        self.assertTrue(escaped.startswith(f"{m.group(1)}\\{m.group(2)}"))
  • Step 2: Run the tests

Run: just test tests/test_properties.py
Expected: PASS (Hypothesis will take a few seconds).

  • Step 3: Commit
just format && just lint && just test
git add tests/test_properties.py
git commit -m "Add property-based tests for Markdown escaping"

Task 10: Documentation

Files:

  • Modify: docs/markdown.md
  • Modify: docs/SECURITY.md
  • Modify: CHANGELOG.md
  • Modify: docs/migration-guide.md

Interfaces:

  • Consumes: all previous tasks.

  • Produces: user-facing documentation of the new behavior.

  • Step 1: Update docs/markdown.md

In the "Unsupported" section, REMOVE this bullet:

- **No HTML escaping in text**: If your Draft.js content contains literal HTML characters like like `<` or `>`, the Markdown output will include them unescaped, which may be interpreted as HTML by Markdown renderers.

ADD a new "Escaping" section (placed before "Unsupported", following the page's existing heading style):

## Escaping

The Markdown exporter escapes user-controlled text so it renders literally rather than as Markdown syntax, following CommonMark backslash-escape rules. This is always on; there is no configuration option.

- Anywhere in text: `\`, `` ` ``, `*`, `_`, `[`, `]`, `<`, `&`.
- At the start of a line: `#`, `-`, `+`, `>`, `=`, `|`, `~`, and ordered list markers like `1.` (rendered as `1\.`). "Start of a line" means the start of a block, after a soft break, or after a list/blockquote marker.
- Link and image URLs inside `](…)` get destination-specific escaping: `\`, `(`, `)` are backslash-escaped and whitespace/control characters are percent-encoded. URL scheme validation (`javascript:` etc.) remains the integrating application's responsibility.
- Code spans and code blocks are never escaped. Instead, the exporter sizes the delimiters to the content: a code span containing a backtick is wrapped in double backticks, and a code block containing a fence gets a fence one character longer.

Limitations:

- Text starting with four or more spaces or a tab may render as an indented code block downstream. Backslash escapes cannot protect leading whitespace.
- GFM-only syntax other than tables (`|`) is not escaped — for example autolinkable bare URLs remain autolinkable.

Also update the "Inline style nesting edge cases" bullet's neighborhood if the escaping section placement shifts numbering — no other content changes.

  • Step 2: Update docs/SECURITY.md

In the "Tampering" section, ADD a third bullet after the two existing ones:

- **The Markdown engine escapes user-controlled text and link destinations.** Block text is backslash-escaped following CommonMark rules (including at line starts, so attacker text cannot inject headings, lists, blockquotes, or link syntax), and URLs emitted into `](…)` destinations have `\`, `(`, `)` escaped and control characters percent-encoded. URL scheme validation remains the integrator's responsibility, exactly as for HTML output: a `"data": {"url": "javascript:alert(1)"}` renders as `[text](javascript:alert(1))` verbatim.
  • Step 3: Update CHANGELOG.md

Under ## [Unreleased], add a ### Changed section (create it if absent):

### Changed

- Markdown export now escapes user-controlled text and link destinations following CommonMark rules, and sizes code span/block delimiters to their content. Markdown output containing metacharacters changes as a result. See [Markdown escaping](https://wagtail.github.io/draftjs_exporter/markdown/#escaping).
  • Step 4: Update docs/migration-guide.md

Add a new section above "Upgrading to v6.0.0" (adjust the version number to whatever the next release is at merge time):

## Upgrading to v6.1.0

### Markdown export now escapes text

The Markdown exporter now escapes user-controlled text and link destinations following CommonMark rules, so content like `#`, `[`, `<`, or `&` in block text renders literally instead of being interpreted as Markdown or HTML. Code spans and code blocks are not escaped; their delimiters are instead sized to the content, which also fixes output corruption when code content contains backticks or fences.

Any Markdown output containing metacharacters in text changes. If your tests compare exact Markdown output, update them. Markdown support remains experimental.
  • Step 5: Check for other docs references

Run: grep -rn "make_code_element\|No HTML escaping\|no escaping" docs/ README.md
Expected: no remaining stale references. Update any found (e.g. if docs/ shows make_code_element(fence) usage, update to the no-argument signature).

  • Step 6: Verify docs build / lint and commit
just format && just lint && just test
git add docs/markdown.md docs/SECURITY.md CHANGELOG.md docs/migration-guide.md
git commit -m "Document Markdown text escaping"

Self-Review Notes

  • Spec coverage: escaping rule set (Tasks 1–2, 6), line-start detection incl. block_prefix (Tasks 3, 6), mark_safe/invariant (Tasks 3–4), link destinations (Tasks 2, 4), code span/block delimiter sizing (Tasks 2–3, 5), snapshot updates (Task 7), integration + property tests (Tasks 8–9), docs/SECURITY/CHANGELOG/migration (Task 10). Breaking-change note: Task 10. Non-goals respected: no importer work, no config option.
  • Type consistency: escape_text(text: str, at_line_start: bool = False) -> str; escape_link_destination(url: str) -> str; code_span_delimiters(content: str) -> tuple[str, str]; code_block_fence(content: str, fence_char: str) -> str; mark_safe(markup: str, block_prefix: bool = False) -> Element; link_destination(url: str) -> Element; prefixed_block(prefix: str, block_prefix: bool = False) -> Component; make_code_element() -> Component; code_span(props: Props) -> Element — all used consistently across tasks.
  • Known deliberate behavior change mid-plan: Task 6 leaves test_exports.py red until Task 7; the tasks may be merged into one commit if preferred.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant