Version analyzed: 0.11.0 | EasyMDE bundled: v2.14.0 (latest: 2.18.0)
Severity: High | Open since 2021
Root cause in widget.html:
const {{ widget.name }}_options = ...
const {{ widget.name }}_editor = new EasyMDE(...){{ widget.name }} is the raw form field name. In Django formsets/admin inlines, names become form-0-body, form-1-body, etc. — hyphens are illegal in JS identifiers, so the const declaration is a syntax error and EasyMDE never initialises.
Additionally, EasyMDE is initialised inline on page load. Dynamically added inline/formset rows (added via Django's "Add another" button) are inserted into the DOM after page load, so no JS fires for them.
Fix:
Replace the JS variable approach with element-ID targeting, and use an IIFE:
{% include 'django/forms/widgets/textarea.html' %}
{{ options | json_script:options_id }}
<script nonce="{{ csp_nonce }}" type="text/javascript">
(function() {
const el = document.getElementById('{{ widget.attrs.id }}');
const opts = JSON.parse(document.getElementById('{{ options_id }}').textContent);
new EasyMDE(Object.assign({
element: el,
hideIcons: ["side-by-side", "preview"],
spellChecker: false,
parsingConfig: { allowAtxHeaderWithoutSpace: true },
}, opts));
})();
</script>Using {{ widget.attrs.id }} (the actual DOM id, which uses underscores) instead of {{ widget.name }} as the variable namespace eliminates the hyphen problem. For dynamic formset rows, a MutationObserver or the formset:added event would be needed to reinitialise on new rows.
Severity: Medium | Maintainer-filed, open since 2023
Root cause in widget.html:3:
<script nonce="{{ csp_nonce }}" type="text/javascript">{{ csp_nonce }} is a template context variable injected by django-csp's context processor. If django-csp is not installed this renders as nonce="", which is harmless without a CSP policy, but breaks when a different CSP library is used (e.g., one that injects request.csp_nonce instead).
Fix: Make the nonce conditional:
{% if csp_nonce %}<script nonce="{{ csp_nonce }}" type="text/javascript">
{% elif request.csp_nonce %}<script nonce="{{ request.csp_nonce }}" type="text/javascript">
{% else %}<script type="text/javascript">{% endif %}Or accept an optional nonce parameter through MDEWidget.__init__ and pass it via get_context, giving the consumer full control.
Severity: Medium | Open since Dec 2024
Root cause in md_admin.css:
background-color: var(--primary);
color: var(--button-fg);
background-color: var(--button-bg);
background-color: var(--button-hover-bg);Django 5.0 overhauled the admin design system and renamed many CSS custom properties. The editor toolbar and CodeMirror area render with fallback colours (usually transparent/black) in Django 5 admin.
Fix: Audit against Django 5's admin/css/dark_mode.css for current variable names and add fallbacks:
.EasyMDEContainer .editor-toolbar {
background-color: var(--primary, #417690);
}
.EasyMDEContainer .editor-toolbar button {
color: var(--button-fg, #fff);
background-color: var(--button-bg, #417690);
}
.EasyMDEContainer .editor-toolbar button:hover,
.EasyMDEContainer .editor-toolbar button.active {
background-color: var(--button-hover-bg, #205067);
}
.EasyMDEContainer .CodeMirror {
color: var(--body-fg, #333);
background-color: var(--body-bg, #fff);
border: 1px solid var(--border-color, #ccc) !important;
}Severity: Medium
Root cause in widgets.py:41-49:
class MDEAdminWidget(MDEWidget):
class Media:
css = {
'all': (
'markdownfield/md_admin.css',
)
}Django's MediaDefiningClass metaclass merges parent and child Media, so both md.css (generic frontend styles) and md_admin.css (admin theme overrides) are loaded together. The generic styles partially conflict with the admin overrides.
Fix: Set extend = False and enumerate all assets explicitly:
class MDEAdminWidget(MDEWidget):
class Media:
extend = False
js = ('markdownfield/easymde/easymde.min.js',)
css = {
'all': (
'markdownfield/easymde/easymde.min.css',
'markdownfield/fontawesome/font-awesome.min.css',
'markdownfield/md_admin.css',
)
}Severity: Low-Medium | Open since 2022
The current VALIDATOR_STANDARD does include img in allowed tags and src/alt/title in allowed attrs, so basic images render. The remaining failure case: when linkify=True (the default), bleach's LinkifyFilter can corrupt image tags by wrapping bare URL strings it finds in src attributes. Also, width and height are missing from MARKDOWN_ATTRS["img"] — any image with those attributes loses them silently.
Fix:
MARKDOWN_ATTRS = {
"*": ["id"],
"img": ["src", "alt", "title", "width", "height"],
"a": ["href", "alt", "title"],
"abbr": ["title"],
}The deeper fix is replacing bleach with ammonia (see Tech Debt below), which handles image sanitisation more correctly.
Severity: Medium | Maintainer-filed, open since 2023
bleach was put into maintenance-only mode by Mozilla in 2023. The ammonia package (Rust-based, available on PyPI) is the actively maintained replacement — faster and handles HTML sanitisation more correctly.
Switching requires replacing bleach.Cleaner in models.py:pre_save and the CSSSanitizer import in validators.py. The Validator dataclass API can remain the same; only the sanitisation backend changes.
Bundled: v2.14.0 (2021). Latest: v2.18.0 (2023).
v2.18.0 includes toolbar customisation improvements, better mobile support, accessibility fixes, and CodeMirror 5 patches. Update the bundled JS/CSS files from the EasyMDE releases page.
MDEWidget already exists and is importable. What is missing is documentation. The minimal working pattern for a frontend ModelForm:
from django import forms
from markdownfield.widgets import MDEWidget
class PostForm(forms.ModelForm):
class Meta:
model = Post
fields = ['title', 'body']
widgets = {
'body': MDEWidget(options={'toolbar': ['bold', 'italic', 'link']}),
}The options dict is passed as JSON directly to EasyMDE's constructor, so any EasyMDE option works. Note that use_editor=True on the model field activates MarkdownFormField (which includes MDEWidget) automatically when rendered via a ModelForm. The use_admin_editor parameter separately controls whether the admin swaps in MDEAdminWidget.
Currently every MarkdownField requires a paired RenderedMarkdownField — two DB columns, always. Rendering happens in pre_save, so changing markdown options requires a data migration to re-render all rows.
Proposed addition (not yet implemented):
body = MarkdownField(rendered_field=None) # render via template filter insteadCombined with a render_markdown template filter, this would suit read-heavy content where re-rendering on every request is acceptable overhead and simpler migrations are preferred.
| Issue | Severity | Status | Fix complexity |
|---|---|---|---|
| Widget broken in formsets/inlines (#13, #38) | High | Broken | Medium |
| django-csp assumed present (#26) | Medium | Broken if not using django-csp | Low |
| Django 5 admin CSS regression (#37) | Medium | Broken on Django 5 | Low |
MDEAdminWidget loads both CSS files (unfiled) |
Medium | Present in all versions | Low |
| Images stripped by linkify interaction (#21) | Low-Med | Partially fixed | Low-Med |
bleach unmaintained (#25) |
Medium | Tech debt | High |
| EasyMDE v2.14.0 bundled, v2.18.0 available (#27) | Low | Stale | Low |
| No frontend widget docs (#20, #15) | Low | Missing | Low |
| Dual DB columns always required (#19) | Low | By design | High |