From 267c202d30ee87658236fc067dbaa8e77ec4542c Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Aug 2026 10:25:35 -0500 Subject: [PATCH 1/4] Fix markdown lists in the nomination statement preview The preview div lives inside `
`, so the site's form-list rules applied to the markdown it renders: `form ul` in style.css dropped the bullets and indent, and `.jobs-form ul li` in mq.css floated every item into a 33%-wide column. A bulleted statement previewed as unbulleted items sitting side by side, overlapping the text above them. Those rules exist for the job form's checkbox lists, so scope the reset to `#statement-preview` rather than changing the shared stylesheets. Ordered lists were never affected -- both rules only target `ul`. Co-Authored-By: Claude Opus 5 (1M context) --- .../templates/nominations/nomination_form.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/nominations/templates/nominations/nomination_form.html b/apps/nominations/templates/nominations/nomination_form.html index d5157e430..09d90e5bf 100644 --- a/apps/nominations/templates/nominations/nomination_form.html +++ b/apps/nominations/templates/nominations/nomination_form.html @@ -97,6 +97,15 @@

Submit a Nomination for {{ election.name }} Election

} #id_previous_service_years input { margin: 0; } #statement-preview { border: 1px solid #ccc; border-radius: 4px; background: #fff; padding: 0.75em 1em; margin-top: 0.5em; } + /* Undo the site's form-list styling (`form ul` in style.css and + `.jobs-form ul li` in mq.css float list items into 33% columns) + so rendered markdown lists look like lists. */ + #statement-preview ul, #statement-preview ol { margin: 0 0 1em 1.5em; } + #statement-preview ul { list-style: square; } + #statement-preview ol { list-style: decimal; } + #statement-preview li { float: none; width: auto; display: list-item; } + #statement-preview li > ul, #statement-preview li > ol { margin-bottom: 0; } + #statement-preview > *:last-child { margin-bottom: 0; } {% for field in form %} From c3eb918689ba8834d03f9ab6b0ed9401f651c68d Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Aug 2026 10:25:45 -0500 Subject: [PATCH 2/4] Fix blockquotes in nomination statements `escape_html=True` ran Django's `escape()` over the raw text before the markdown parser saw it, so `>` arrived as `>` and blockquotes rendered as literal text. This hit stored statements as well as the preview, since both go through the field's `pre_save`. Pre-escaping is no longer what keeps the field safe: pydotorg.markup wraps every renderer in `nh3.clean` with a tag, attribute, and URL-scheme allowlist, so raw HTML is dropped from the rendered output instead. Dropping the flag lets markdown syntax through and leaves sanitization in one place. Statements saved before this change keep their cached HTML until next saved. Co-Authored-By: Claude Opus 5 (1M context) --- apps/nominations/models.py | 4 ++- apps/nominations/tests/test_models.py | 36 ++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/nominations/models.py b/apps/nominations/models.py index 17b62c697..214ece8a3 100644 --- a/apps/nominations/models.py +++ b/apps/nominations/models.py @@ -261,7 +261,9 @@ class Nomination(models.Model): previous_board_service = models.CharField(max_length=1024, blank=False, null=True) # noqa: DJ001 employer = models.CharField(max_length=1024, blank=False, null=True) # noqa: DJ001 other_affiliations = models.CharField(max_length=2048, blank=True, null=True) # noqa: DJ001 - nomination_statement = MarkupField(escape_html=True, markup_type="markdown", blank=False, null=True) + # No escape_html: it escapes `>` too and breaks blockquotes. pydotorg.markup + # sanitizes every renderer's output, so raw HTML is dropped there instead. + nomination_statement = MarkupField(markup_type="markdown", blank=False, null=True) nominator = models.ForeignKey(User, related_name="nominations_made", on_delete=models.CASCADE) nominee = models.ForeignKey( diff --git a/apps/nominations/tests/test_models.py b/apps/nominations/tests/test_models.py index b6ccf3172..bfd86146d 100644 --- a/apps/nominations/tests/test_models.py +++ b/apps/nominations/tests/test_models.py @@ -3,7 +3,7 @@ from django.conf import settings from django.test import TestCase -from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind +from apps.nominations.models import DEFAULT_ACCENT_COLOR, Election, ElectionKind, Nomination class ElectionKindModelTests(TestCase): @@ -70,3 +70,37 @@ def test_markdown_preserves_safe_links_and_formatting(self): def test_restructuredtext_strips_javascript_uri(self): rendered = self._render("restructuredtext", "`x `_") self.assertNotIn("javascript:", rendered) + + +class NominationStatementRenderingTests(TestCase): + """The statement pipeline must allow markdown but never raw HTML.""" + + def render(self, text): + return Nomination.render_statement(text) + + def test_blockquote_renders(self): + self.assertIn("
", self.render("> quoted")) + + def test_lists_render(self): + html = self.render("- one\n- two") + self.assertIn("
    ", html) + self.assertEqual(html.count("
  • "), 2) + + def test_headings_and_emphasis_render(self): + html = self.render("# Title\n\n**bold** and *italic*") + self.assertIn("

    Title

    ", html) + self.assertIn("bold", html) + self.assertIn("italic", html) + + def test_script_is_dropped(self): + html = self.render("") + self.assertNotIn("script", html) + self.assertNotIn("alert(1)", html) + + def test_event_handler_inside_blockquote_is_dropped(self): + html = self.render("> ") + self.assertIn("
    ", html) + self.assertNotIn("onerror", html) + + def test_unsafe_link_scheme_is_dropped(self): + self.assertNotIn("javascript:", self.render("[x](javascript:alert(1))")) From 079dd31744e2ae59f2e315f6ed65a7af7569d8b5 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Aug 2026 12:49:25 -0500 Subject: [PATCH 3/4] Update apps/nominations/templates/nominations/nomination_form.html --- apps/nominations/templates/nominations/nomination_form.html | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/nominations/templates/nominations/nomination_form.html b/apps/nominations/templates/nominations/nomination_form.html index 09d90e5bf..e3ad0b469 100644 --- a/apps/nominations/templates/nominations/nomination_form.html +++ b/apps/nominations/templates/nominations/nomination_form.html @@ -97,9 +97,7 @@

    Submit a Nomination for {{ election.name }} Election

    } #id_previous_service_years input { margin: 0; } #statement-preview { border: 1px solid #ccc; border-radius: 4px; background: #fff; padding: 0.75em 1em; margin-top: 0.5em; } - /* Undo the site's form-list styling (`form ul` in style.css and - `.jobs-form ul li` in mq.css float list items into 33% columns) - so rendered markdown lists look like lists. */ + /* Fix site styling specifically for elections */ #statement-preview ul, #statement-preview ol { margin: 0 0 1em 1.5em; } #statement-preview ul { list-style: square; } #statement-preview ol { list-style: decimal; } From 2872331616a830e6cb7885888e62626596454c22 Mon Sep 17 00:00:00 2001 From: Jacob Coffee Date: Wed, 5 Aug 2026 12:49:31 -0500 Subject: [PATCH 4/4] Update apps/nominations/models.py --- apps/nominations/models.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/nominations/models.py b/apps/nominations/models.py index 214ece8a3..88aa5fc80 100644 --- a/apps/nominations/models.py +++ b/apps/nominations/models.py @@ -261,8 +261,6 @@ class Nomination(models.Model): previous_board_service = models.CharField(max_length=1024, blank=False, null=True) # noqa: DJ001 employer = models.CharField(max_length=1024, blank=False, null=True) # noqa: DJ001 other_affiliations = models.CharField(max_length=2048, blank=True, null=True) # noqa: DJ001 - # No escape_html: it escapes `>` too and breaks blockquotes. pydotorg.markup - # sanitizes every renderer's output, so raw HTML is dropped there instead. nomination_statement = MarkupField(markup_type="markdown", blank=False, null=True) nominator = models.ForeignKey(User, related_name="nominations_made", on_delete=models.CASCADE)