Skip to content

Add experimental Markdown importer - #160

Open
thibaudcolas wants to merge 29 commits into
mainfrom
new-importer
Open

Add experimental Markdown importer#160
thibaudcolas wants to merge 29 commits into
mainfrom
new-importer

Conversation

@thibaudcolas

Copy link
Copy Markdown
Member

Summary

Adds an experimental Markdown importer, complementing the existing Markdown exporter: MarkdownImporter converts Markdown text into a Draft.js ContentState. Dependency-free, no new install requirements.

Two-phase architecture (design spec: docs/superpowers/specs/2025-07-21-markdown-importer-design.md):

  1. MarkdownParser — hand-written parser covering the CommonMark core: paragraphs, ATX headings, blockquotes, fenced code, thematic breaks, nested lists with depth tracking, bold/italic/code, links, images, hard breaks. Guarantees structural integrity: every input produces a valid ContentState or raises MarkdownParseError (with line numbers).
  2. ContentStateFilter — declarative content policy on the parsed result (remove / keep / demote / callables), reusable on any ContentState. E.g. demoting level-1 headings: {"type": "block", "match": "header-one", "action": "demote"}.

Configurable entity resolution for links and images: resolver chains route URLs to typed entities, with a shipped scheme_resolver helper for internal URL schemes (wagtail://image?id=10&format=left → fully-populated IMAGE entity; plain /media/...jpg → default src/alt). A configurable inline_html_styles whitelist imports paired tags like <sup> as inline styles; all other HTML passes through as literal text, so there is no markup injection surface.

The parser is referenced by dotted path in config, so an alternative engine (e.g. backed by a full CommonMark parser) can be swapped in later.

Supersedes the earlier first-draft approach on the markdown-importer branch with the approved class-based API (MarkdownImporter(config).import_markdown(md)).

Test evidence

  • just test — 587 passed (unit, integration, snapshot, property-based)
  • just test-coverage — 100% on all new modules, 100% project-wide
  • just test-compatibility — 587 passed on Python 3.10 with pinned old deps
  • just lint — ruff check, ruff format, mypy, ty all clean
  • just docs-build — clean

Snapshot coverage reuses the existing test_exports.json fixtures: each fixture's recorded Markdown output is imported and compared against its ContentState, with explicit "import" overrides documenting known information loss (e.g. single-tilde strikethrough, entity metadata Markdown cannot carry).

Included

  • New packages: markdown_parser, contentstate_filter, markdown_importer
  • Public API: MarkdownImporter, MarkdownParser, ContentStateFilter, scheme_resolver, ImporterConfig, ParserConfig, FilterRule, EntityResolver, EntityResolution, MarkdownParseError
  • Docs page (docs/markdown-importer.md), nav entry, changelog entry, example.py import demo, agent skill update

@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown

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

  • ✅ Successfully posted inline: 18 comment(s)
  • 📝 In summary (no line info): 1 comment(s)

📄 tests/test_exports.json

⚠️ GitHub could not post this as an inline comment: No line information provided

The text at line 1735 contains "~Overlapping ~~te~xt styles. <strong style=\"text-decoration: underline;\">Custom styles</strong> too!". The escaped quotes " in JSON represent a literal backslash followed by a quote character. This appears intentional as test data for HTML-like content. No issues found - duplicate entityMap key concern from review plan is not present in this file.

Comment thread draftjs_exporter/markdown_parser/__init__.py
Comment on lines +20 to +21
class ParserConfig(TypedDict, total=False):
"""Options controlling which Markdown constructs are recognized."""

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 ParserConfig TypedDict documents feature toggles but doesn't specify what the default values are. Implementation uses True for most features, but this is only discoverable by reading the parse() method. Consider adding inline comments or a class-level docstring noting that boolean options default to True and list options default to [].

Suggestion:

Suggested change
class ParserConfig(TypedDict, total=False):
"""Options controlling which Markdown constructs are recognized."""
class ParserConfig(TypedDict, total=False):
"""Options controlling which Markdown constructs are recognized.
All boolean options default to ``True``; list options default to ``[]``.
"""

Comment thread draftjs_exporter/markdown_parser/__init__.py
Comment thread draftjs_exporter/markdown_parser/__init__.py
Comment thread draftjs_exporter/contentstate_filter/__init__.py
Comment thread tests/markdown_parser/test_inline.py
Comment thread tests/markdown_parser/test_parser.py
Comment thread tests/markdown_parser/test_resolvers.py
Comment thread tests/markdown_parser/test_resolvers.py
Comment thread tests/test_properties.py
Comment on lines +112 to +113
for block in content_state.get("blocks", []):
kept = self._apply_block_rule(copy.deepcopy(block), block_rules)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unnecessary deep copy when no block rules match. The copy.deepcopy(block) at line 113 is always called, but when no block rules match (line 188-189 returns early), the deep copy is wasteful. Consider passing the original block and only copying if rules exist.

from draftjs_exporter.error import ConfigException
from draftjs_exporter.types import Block, ContentState, Entity, InlineStyleRange

FilterCallback: TypeAlias = Callable[[Any], Any]

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 FilterCallback type alias uses Callable[[Any], Any] which is too permissive. Custom callbacks receive either Block, str (for inline_style), or Entity objects. Consider using a Union type or overloaded types for better static type checking.

Comment on lines +49 to +60
def import_markdown(self, markdown: str) -> ContentState:
"""Parse Markdown and apply filter rules.

Parameters:
markdown: The Markdown text to import.

Returns:
The parsed, filtered ContentState.

Raises:
MarkdownParseError: If the input cannot be parsed.
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Docstring states MarkdownParseError is raised, but import_string raises ImportError for invalid parser paths. Either wrap the import or update the docstring to document all possible exceptions.

"""
if config is None:
config = {}
parser_class = import_string(config.get("parser", DEFAULT_PARSER))

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 parser config option accepts arbitrary dotted paths passed to import_string. If user-controlled input flows here, it could enable arbitrary code execution. Consider documenting this risk or adding path validation for untrusted config sources.

Comment on lines +47 to +53
key = len(self.entity_map)
self.entity_map[str(key)] = {
"type": type_,
"mutability": mutability,
"data": data,
}
return key

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Entity key type mismatch: add_entity stores entities with string keys (str(key)) in entity_map (typed dict[str, Entity]), but returns key as an int. Callers use the returned integer in entity_ranges (e.g., blocks.py line 197: {"offset": 0, "length": 1, "key": key}). When the HTML exporter looks up entities via entity_map.get(entity_key) with an integer key, the string-keyed lookup will return None, silently dropping entities. Either return str(key) instead of int, or store entities with integer keys and update the EntityMap type alias.

Suggestion:

Suggested change
key = len(self.entity_map)
self.entity_map[str(key)] = {
"type": type_,
"mutability": mutability,
"data": data,
}
return key
key = len(self.entity_map)
self.entity_map[str(key)] = {
"type": type_,
"mutability": mutability,
"data": data,
}
return str(key)

}
)
cs = parser.parse("[label](wagtail://page?id=3)")
self.assertEqual(cs["entityMap"]["0"]["data"], {"id": 3})

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 accesses entityMap with string key '0', but builder.add_entity returns int. The test passes only because the builder stores entities under str(key) ('0') instead of int(key) (0). This masks the underlying bug in builder.py where the return type annotation promises int (per entityRanges contract) but the map uses string keys. If builder.py is fixed to store integer keys, this assertion would raise KeyError and need to be updated to cs['entityMap'][0]. This is a test-level symptom of the builder.py bug; fixing builder.py will require updating this test accordingly.

Suggestion:

Suggested change
self.assertEqual(cs["entityMap"]["0"]["data"], {"id": 3})
self.assertEqual(cs["entityMap"][0]["data"], {"id": 3})

Comment on lines +47 to +48
referenced = {str(r["key"]) for b in cs["blocks"] for r in b["entityRanges"]}
self.assertEqual(set(cs["entityMap"].keys()), referenced)

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's assertion explicitly converts entity range keys to strings ({str(r["key"]) for ...}) before comparing against entityMap.keys(). This works around the builder.py inconsistency (where entityMap uses string keys while entityRanges use integers) rather than exposing it. Once builder.py is fixed to use integer keys, this str() conversion should be removed and the assertion changed to self.assertEqual(set(cs['entityMap'].keys()), referenced) with referenced = {r['key'] for ...}.

Suggestion:

Suggested change
referenced = {str(r["key"]) for b in cs["blocks"] for r in b["entityRanges"]}
self.assertEqual(set(cs["entityMap"].keys()), referenced)
referenced = {r["key"] for b in cs["blocks"] for r in b["entityRanges"]}
self.assertEqual(set(cs["entityMap"].keys()), referenced)

Comment on lines +199 to +203
def test_links_disabled(self):
parser, builder = parse_with_builder(links=False)
text, _, entities = parser.parse("[a](/b)")
self.assertEqual(text, "[a](/b)")
self.assertEqual(entities, [])

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 variable builder is created and assigned but never used in this test. Either remove it, or use it to verify the entity was not created in the entity map.

Comment thread tests/test_properties.py
Comment thread tests/test_properties.py
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