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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@

## [Unreleased]

### 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).

## [v6.0.0](https://github.com/wagtail/draftjs_exporter/releases/tag/v6.0.0)

### Added
Expand Down
11 changes: 11 additions & 0 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,17 @@ Add a tagged-release workflow that builds with `uv`, publishes to PyPI, updates

Provide a cookbook showing how to prompt LLMs to draft block maps/entity decorators from HTML samples, and scripts to turn that output into typed configs and fixtures.

### Better automated code review

Improve our AI code review setup (currently OpenCodeReview in CI) based on false-positive patterns observed on real PRs:

- **Require evidence before breakage claims.** Findings that claim behavior is broken ("X will never match") should be verified against the test suite first — several flagged "bugs" were directly refuted by existing passing tests the bot itself cited.
- **Verify type-coercion claims against the code.** Claims about type mismatches should check the actual data flow (e.g. attribute stringification in `DOM.create_element`) before asserting a comparison can never hold.
- **Deduplicate findings.** The same issue was reported three times across two runs (a cosmetic comparison-idiom nit). Group repeats into a single finding.
- **Suppress non-findings.** Comments that suggest exactly the code as written, or conclude "no issue", should not be posted.
- **Calibrate severity by failure mode.** Distinguish "fails safe / cosmetic / hypothetical misuse" from real defects, and only post inline comments for actionable items.
- **Domain-aware reasoning.** Findings about Markdown/CommonMark behavior should be checked against the spec (e.g. what is legal inside a link destination) rather than pattern-matched from HTML escaping.

## Experimental

> Possible changes that require R&D, and high-risk ideas that could bring large benefits but with likely trade-offs.
Expand Down
1 change: 1 addition & 0 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Out of scope. Depends entirely on the integrating application.
- **Entity `data` is passed straight through into element attributes.** [`EntityState.render_entities`](https://github.com/wagtail/draftjs_exporter/blob/main/draftjs_exporter/entity_state.py) copies `entity_details["data"]` into the props of the element the entity decorator renders, e.g. a `LINK` entity's `url` typically becomes an `href`. Attribute values are HTML-escaped by every engine (`html.escape` in the `string` engine, BeautifulSoup/lxml elsewhere), which prevents attribute/quote breakout, but the library doesn't validate URL schemes. A malicious `"data": {"url": "javascript:alert(1)"}` will render as `href="javascript:alert(1)"` verbatim. Entity decorators that emit `href`, `src`, or similar URL-bearing attributes must validate the scheme (allow-list `http`/`https`/`mailto`) themselves.
- **`style` props are interpolated without CSS validation.** A style dict is converted to an inline `style="..."` string by joining property/value pairs; values aren’t validated as safe CSS, only escaped for the surrounding attribute quotes. Entity/decorator code that forwards untrusted `data` into `style` props can inject arbitrary CSS declarations (e.g. `url()`-based exfiltration).
- **`DOM.parse_html()` / `Elt.from_html()` intentionally bypass escaping.** This exists so integrators can render pre-sanitized, developer-controlled HTML fragments. If a decorator instead feeds it attacker-controlled text (e.g. raw entity data), that text is emitted into the page unescaped. That’s a stored-XSS vector. Never call `parse_html` with data sourced from ContentState.
- **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.

### Repudiation

Expand Down
75 changes: 71 additions & 4 deletions docs/markdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ from draftjs_exporter import BLOCK_MAP as HTML_BLOCK_MAP, BLOCK_TYPES, ENTITY_TY
from draftjs_exporter.markdown.blocks import list_wrapper, make_ul, ol, prefixed_block
from draftjs_exporter.markdown.code import code_element, code_wrapper
from draftjs_exporter.markdown.entities import image, link, make_horizontal_rule
from draftjs_exporter.markdown.styles import inline_style
from draftjs_exporter.markdown.styles import code_span, inline_style

config = {
"engine": "draftjs_exporter.engines.markdown.DOMMarkdown",
Expand All @@ -125,7 +125,7 @@ config = {
"element": ol,
"wrapper": list_wrapper,
},
BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> "),
BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> ", block_prefix=True),
BLOCK_TYPES.CODE: {
"element": code_element,
"wrapper": code_wrapper,
Expand All @@ -135,7 +135,7 @@ config = {
HTML_STYLE_MAP,
**{
INLINE_STYLES.BOLD: inline_style("__"),
INLINE_STYLES.CODE: inline_style("`"),
INLINE_STYLES.CODE: code_span,
INLINE_STYLES.ITALIC: inline_style("*"),
INLINE_STYLES.STRIKETHROUGH: inline_style("~~"),
},
Expand All @@ -148,6 +148,74 @@ config = {
}
```

## 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: `\`, `` ` ``, `*`, `[`, `]`, `<`, plus `_` where it could form emphasis. Intraword underscore runs (as in `snake_case`) are left unescaped — CommonMark's flanking rules guarantee they are inert.
- 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 any line ending (`\n`, `\r\n`, or a lone `\r`), or after a list/blockquote marker. The line-start rules also apply after any leading run of spaces, since CommonMark allows block constructs to be indented.
- 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.

For example, block text that looks like Markdown syntax renders literally:

```python
from draftjs_exporter import HTML, MARKDOWN_CONFIG

exporter = HTML(MARKDOWN_CONFIG)
exporter.render({
"entityMap": {},
"blocks": [{
"key": "6m5fh",
"text": "# Not a heading, *not emphasis*, <b>not HTML</b>, &copy;",
"type": "unstyled",
"depth": 0,
"inlineStyleRanges": [],
"entityRanges": [],
}],
})
# "\\# Not a heading, \\*not emphasis\\*, \\<b>not HTML\\</b>, &copy;\n\n"
```

### Escaping in custom components

When you write custom Markdown components, any plain strings they emit are escaped as user text. Wrap structural syntax with `mark_safe` so it renders verbatim, and route URLs through `link_destination`:

```python
from draftjs_exporter import DOM, Element, Props
from draftjs_exporter.markdown.helpers import link_destination, mark_safe


def mention(props: Props) -> Element:
"""Render a mention entity as a Markdown link to a profile."""
return DOM.create_element(
"fragment",
{},
[
mark_safe("[@"),
props["children"], # Plain text, escaped automatically.
mark_safe("]("),
link_destination(props["url"]), # Escaped for ](…) destinations.
mark_safe(")"),
],
)
```

Use `mark_safe(markup, block_prefix=True)` for prefixes after which nested blocks can start (list markers, blockquote `> `), so text following them still gets line-start escaping. The escaping functions are also available directly for lower-level needs:

```python
from draftjs_exporter.markdown.escape import escape_link_destination, escape_text

escape_text("# Not a heading", at_line_start=True) # "\\# Not a heading"
escape_link_destination("https://example.com/a(b)") # "https://example.com/a\\(b\\)"
```

Limitations:

- Entity references (`&copy;`, `&#60;`) are not escaped: they decode to their character downstream rather than rendering literally. They cannot inject markup, so this is a fidelity trade-off, not a security issue.
- 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.

## Unsupported

The Markdown exporter has inherent limitations compared to the HTML exporter.
Expand All @@ -157,5 +225,4 @@ The Markdown exporter has inherent limitations compared to the HTML exporter.
- **No table support**: Draft.js has no built-in table block type, and the exporter does not attempt to generate Markdown tables from custom block types.
- **No Setext-style headings**: Headings always use ATX style (`# Heading`).
- **Entity data fidelity**: The Markdown link syntax `[text](url)` only preserves the URL.
- **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.
- **Inline style nesting edge cases**: When bold and italic styles partially overlap, the exporter may produce markers that some strict Markdown parsers reject (e.g. `**Bold **_Italic_**`). Most renderers handle this correctly, but it is not guaranteed by the CommonMark spec.
8 changes: 8 additions & 0 deletions docs/migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

This page collects the upgrade notes for each major (and selected minor) release of the exporter. Each section describes the breaking changes and the steps to follow when upgrading from the previous version. For the full list of changes in each release, see the [CHANGELOG](https://github.com/wagtail/draftjs_exporter/blob/main/CHANGELOG.md).

## 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.

## Upgrading to v6.0.0

### Python 3.7, 3.8, and 3.9 support removed
Expand Down
16 changes: 15 additions & 1 deletion docs/reference/example.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ elements.
- With arbitrary nesting.
- Common text styles: **Bold**, _Italic_, <u>Underline</u>, `Monospace`, ~Strikethrough.~ cmd + b
- ~Overlapping ~**~te~****_xt_**_ styles. _Custom styles too!
- #hashtag support via [#CompositeDecorators](https://github.com/wagtail/draftjs_exporter/pull/17).
- \#hashtag support via [#CompositeDecorators](https://github.com/wagtail/draftjs_exporter/pull/17).
- Linkify URLs too! [http://example.com/](http://example.com/)
- Depth can go back and forth, it works fiiine (1)
- Depth can go back and forth, it works fiiine (2)
Expand Down Expand Up @@ -245,3 +245,17 @@ def blockquote(props):
Discarded block but the content stays.<div>Render as <span class="missing-entity">div</span></div>Voilà!



## Markdown escaping

\# Not a heading, \*not emphasis\*, \<b>not HTML\</b>

1\. Not a list item

````
A code block containing ``` gets a longer fence
````

[A link](https://example.com/search?q=\(draft\))


86 changes: 79 additions & 7 deletions draftjs_exporter/engines/markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
from html import escape

from draftjs_exporter.engines.base import Attr, DOMEngine
from draftjs_exporter.markdown.escape import (
code_block_fence,
code_span_delimiters,
escape_text,
)
from draftjs_exporter.types import HTML, Tag

# http://w3c.github.io/html/single-page.html#void-elements
Expand All @@ -28,7 +33,9 @@
class Elt:
"""A DOM element that the Markdown engine manipulates.

Identical to the string engine's Elt, but rendering does not escape text.
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).
"""

__slots__ = ("type", "attr", "children", "markup")
Expand Down Expand Up @@ -60,7 +67,12 @@ def from_html(markup: HTML) -> "Elt":


class DOMMarkdown(DOMEngine):
"""String concatenation implementation of the DOM API for Markdown output."""
"""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.
"""

@staticmethod
def create_tag(type_: Tag, attr: Attr | None = None) -> Elt:
Expand Down Expand Up @@ -118,17 +130,66 @@ def render_attrs(attr: Attr) -> str:

@staticmethod
def render_children(children: list[HTML | Elt]) -> HTML:
"""Render a list of children to a string without escaping text.
"""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 carriage return, or right after a ``mark_safe``
element created with ``block_prefix``.

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

Returns:
The concatenated child content.
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
Comment on lines +154 to +159

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

elif rendered:
at_line_start = rendered.endswith(("\n", "\r"))
Comment on lines +154 to +161

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 at_line_start tracking for consecutive mark_safe elements without block_prefix could become inconsistent if an element has empty content or content not ending with newlines. However, this appears to be intentional behavior (mid-line context). The current logic correctly handles this case by only updating at_line_start to True when block_prefix is set.

Suggestion:

Suggested change
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", "\r"))
# Behavior is correct, but consider adding a test case for consecutive
# mark_safe elements without block_prefix to document expected behavior

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

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.

return "".join(out)

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

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 a new public method used internally by code_span and code_block rendering, but has no dedicated unit tests. While it's indirectly tested through those features, having direct unit tests would improve maintainability and document its behavior.

Suggestion:

Suggested change
@staticmethod
def flatten_text(children: list["str | Elt"]) -> str:
@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.
"""
# Consider adding unit tests for this method directly

Comment on lines +168 to +169

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

"""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.
"""
return "".join(
[DOMMarkdown.render(c) if isinstance(c, Elt) else c for c in children]
)
parts = []
for c in children:
if isinstance(c, Elt):
if c.type == "mark_safe":
parts.append(c.attr["markup"] if c.attr else "")
Comment on lines +184 to +185

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 on lines +184 to +185

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 c.attr['markup'] which raises KeyError if attr exists but lacks the 'markup' key. While the mark_safe helper always includes 'markup' in the attr dictionary, other code creating mark_safe elements directly could pass an empty dict or a dict without 'markup'. The code should use c.attr.get('markup', '') to be defensive.

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 +184 to +185

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 "")

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

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. While the test coverage is extensive for common cases, deeply nested element structures (which could occur in complex Draft.js content) could cause a RecursionError. Consider using an iterative approach with a stack if the depth is unbounded.

Suggestion:

Suggested change
else:
parts.append(DOMMarkdown.flatten_text(c.children))
else:
# Consider iterative approach to avoid recursion limits on deeply nested structures

Comment on lines +188 to +189

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

else:
parts.append(c)
return "".join(parts)

@staticmethod
def render(elt: Elt) -> HTML:
Expand All @@ -150,8 +211,19 @@ def render(elt: Elt) -> HTML:
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 _:
Expand Down
12 changes: 6 additions & 6 deletions draftjs_exporter/markdown/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
entity_fallback,
style_fallback,
)
from draftjs_exporter.markdown.styles import inline_style
from draftjs_exporter.markdown.styles import code_span, inline_style
from draftjs_exporter.types import Component, ConfigMap


Expand Down Expand Up @@ -72,7 +72,7 @@ class MarkdownOptions(TypedDict, total=False):
"element": ol,
"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.

BLOCK_TYPES.CODE: {
"element": code_element,
"wrapper": code_wrapper,
Expand All @@ -85,7 +85,7 @@ class MarkdownOptions(TypedDict, total=False):
HTML_STYLE_MAP,
**{
INLINE_STYLES.BOLD: inline_style("**"),
INLINE_STYLES.CODE: inline_style("`"),
INLINE_STYLES.CODE: code_span,
INLINE_STYLES.ITALIC: inline_style("_"),
INLINE_STYLES.STRIKETHROUGH: inline_style("~"),
INLINE_STYLES.FALLBACK: style_fallback,
Expand Down Expand Up @@ -147,9 +147,9 @@ def build_markdown_config(options: MarkdownOptions | None = None) -> ExporterCon
"element": make_ol(ol_delimiter),
"wrapper": list_wrapper,
},
BLOCK_TYPES.BLOCKQUOTE: prefixed_block("> "),
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.

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

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.

},
BLOCK_TYPES.ATOMIC: lambda props: props["children"],
Expand All @@ -158,7 +158,7 @@ def build_markdown_config(options: MarkdownOptions | None = None) -> ExporterCon
style_map: ConfigMap = {
**HTML_STYLE_MAP,
INLINE_STYLES.BOLD: inline_style(bold),
INLINE_STYLES.CODE: inline_style("`"),
INLINE_STYLES.CODE: code_span,
INLINE_STYLES.ITALIC: inline_style(italic),
INLINE_STYLES.STRIKETHROUGH: inline_style(strikethrough),
}
Expand Down
Loading
Loading