Skip to content

Instantly share code, notes, and snippets.

@dmptrluke
Created March 8, 2026 22:37
Show Gist options
  • Select an option

  • Save dmptrluke/20c345ea57283a8ec53b419dd243d26d to your computer and use it in GitHub Desktop.

Select an option

Save dmptrluke/20c345ea57283a8ec53b419dd243d26d to your computer and use it in GitHub Desktop.
django-markdownfield issue analysis & fix report

django-markdownfield Issue Analysis & Fix Report

Version analyzed: 0.11.0 | EasyMDE bundled: v2.14.0 (latest: 2.18.0)


Bug #1 — Widget breaks in formsets and admin inlines (Issues #13, #38)

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.


Bug #2 — django-csp assumed present (Issue #26)

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.


Bug #3 — Django 5 admin CSS variable names broke admin theme (Issue #37)

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;
}

Bug #4 — MDEAdminWidget.Media loads both CSS files (unfiled)

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',
            )
        }

Bug #5 — Images may be stripped depending on linkify interaction (Issue #21)

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.


Tech Debt — bleach is unmaintained (Issue #25)

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.


Feature Gap — Outdated EasyMDE (Issue #27)

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.


Feature Gap — No frontend (non-admin) MDEWidget documentation (Issues #20, #15)

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.


Feature Gap — Dual DB columns always required (Issue #19)

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 instead

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


Summary

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment