diff --git a/reviewboard/reviews/markdown_utils.py b/reviewboard/reviews/markdown_utils.py
index d359692f178eed296b7e076be9283dd8606b44d0..33ad3ea8f2025f6bb13509f3520757b35a86f605 100644
--- a/reviewboard/reviews/markdown_utils.py
+++ b/reviewboard/reviews/markdown_utils.py
@@ -1,8 +1,10 @@
 from __future__ import unicode_literals
 
 import re
+from xml.dom.minidom import parseString
 
-from markdown import Markdown
+from djblets.util.compat.six.moves import cStringIO as StringIO
+from markdown import Markdown, markdown, markdownFromFile
 
 
 # NOTE: Any changes made here or in markdown_escape below should be
@@ -50,6 +52,15 @@ ESCAPE_CHARS_RE = re.compile(r"""
     re.M | re.VERBOSE)
 UNESCAPE_CHARS_RE = re.compile(r'\\([%s])' % MARKDOWN_SPECIAL_CHARS)
 
+# Keyword arguments used when calling a Markdown renderer function.
+MARKDOWN_KWARGS = {
+    'safe_mode': 'escape',
+    'output_format': 'xhtml1',
+    'extensions': [
+        'fenced_code', 'codehilite', 'sane_lists', 'smart_strong'
+    ],
+}
+
 
 def markdown_escape(text):
     """Escapes text for use in Markdown.
@@ -95,3 +106,91 @@ def markdown_set_field_escaped(model, field, escaped):
         markdown_escape_field(model, field)
     else:
         markdown_unescape_field(model, field)
+
+
+def iter_markdown_lines(markdown_html):
+    """Iterates over lines of Markdown, normalizing for individual display.
+
+    Generated Markdown HTML cannot by itself be handled on a per-line-basis.
+    Code blocks, for example, will consist of multiple lines of content
+    contained within a <pre> tag. Likewise, lists will be a bunch of
+    <li> tags inside a <ul> tag, and individually do not form valid lists.
+
+    This function iterates through the Markdown tree and generates
+    self-contained lines of HTML that can be rendered individually.
+    """
+    nodes = get_markdown_element_tree(markdown_html)
+
+    for node in nodes:
+        if node.nodeType == node.ELEMENT_NODE:
+            if (node.tagName == 'div' and
+                node.attributes.get('class', 'codehilite')):
+                # This is a code block, which will consist of a bunch of lines
+                # for the source code. We want to split that up into
+                # individual lines with their own <pre> tags.
+                for line in node.toxml().splitlines():
+                    yield '<pre>%s</pre>' % line
+            elif node.tagName in ('ul', 'ol'):
+                # This is a list. We'll need to split all of its items
+                # into individual lists, in order to retain bullet points
+                # or the numbers.
+                #
+                # For the case of numbers, we can set each list to start
+                # at the appropriate number so that they don't all say "1."
+                i = node.attributes.get('start', 1)
+
+                for child_node in node.childNodes:
+                    if (child_node.nodeType == child_node.ELEMENT_NODE and
+                        child_node.tagName == 'li'):
+                        # This is a list item element. It may be multiple
+                        # lines, but we'll have to treat it as one line.
+                        yield '<%s start="%s">%s</%s>' % (
+                            node.tagName, i, child_node.toxml(),
+                            node.tagName)
+
+                        i += 1
+            elif node.tagName == 'p':
+                # This is a paragraph, possibly containing multiple lines.
+                for line in node.toxml().splitlines():
+                    yield line
+            else:
+                # Whatever this is, treat it as one block.
+                yield node.toxml()
+        elif node.nodeType == node.TEXT_NODE:
+            # This may be several blank extraneous blank lines, due to
+            # Markdown's generation from invisible markup like fences.
+            # We want to condense this down to one blank line.
+            yield '\n'
+
+
+def get_markdown_element_tree(markdown_html):
+    """Returns an XML element tree for Markdown-generated HTML.
+
+    This will build the tree and return all nodes representing the rendered
+    Markdown content.
+    """
+    doc = parseString('<html>%s</html>' % markdown_html)
+    return doc.childNodes[0].childNodes
+
+
+def render_markdown(text):
+    """Renders Markdown text to HTML.
+
+    The Markdown text will be sanitized to prevent injecting custom HTML.
+    It will also enable a few plugins for code highlighting and sane lists.
+    """
+    return markdown(text, **MARKDOWN_KWARGS)
+
+
+def render_markdown_from_file(f):
+    """Renders Markdown text to HTML.
+
+    The Markdown text will be sanitized to prevent injecting custom HTML.
+    It will also enable a few plugins for code highlighting and sane lists.
+    """
+    s = StringIO()
+    markdownFromFile(input=f, output=s, **MARKDOWN_KWARGS)
+    html = s.getvalue()
+    s.close()
+
+    return html
diff --git a/reviewboard/reviews/ui/markdownui.py b/reviewboard/reviews/ui/markdownui.py
index 45dda0b35fdcabcac8655498534240d503a69905..463472f8f14c0525cfb63bb36245e690f5410cc6 100644
--- a/reviewboard/reviews/ui/markdownui.py
+++ b/reviewboard/reviews/ui/markdownui.py
@@ -3,10 +3,11 @@ from __future__ import unicode_literals
 import logging
 from xml.dom.minidom import parseString
 
-from djblets.util.compat.six.moves import cStringIO as StringIO
-import markdown
+from pygments.lexers import TextLexer
 
 from reviewboard.reviews.ui.text import TextBasedReviewUI
+from reviewboard.reviews.markdown_utils import (iter_markdown_lines,
+                                                render_markdown_from_file)
 
 
 class MarkdownReviewUI(TextBasedReviewUI):
@@ -24,31 +25,16 @@ class MarkdownReviewUI(TextBasedReviewUI):
     js_view_class = 'RB.MarkdownReviewableView'
 
     def generate_render(self):
-        buffer = StringIO()
-        self.obj.file.open()
-        markdown.markdownFromFile(input=self.obj.file, output=buffer,
-                                  output_format='xhtml1', safe_mode='escape',
-                                  extensions=['fenced_code', 'codehilite'])
-        rendered = buffer.getvalue()
-        buffer.close()
-        self.obj.file.close()
+        with self.obj.file as f:
+            f.open()
+            rendered = render_markdown_from_file(f)
 
         try:
-            doc = parseString('<html>%s</html>' % rendered)
-            main_node = doc.childNodes[0]
-
-            for node in main_node.childNodes:
-                for html in self._process_markdown_html(node):
-                    yield html
+            for line in iter_markdown_lines(rendered):
+                yield line
         except Exception as e:
             logging.error('Failed to parse resulting Markdown XHTML for '
                           'file attachment %d: %s' % (self.obj.pk, e))
 
-    def _process_markdown_html(self, node):
-        if (node.nodeType == node.ELEMENT_NODE and
-            node.tagName == 'div' and
-            node.attributes.get('class', 'codehilite')):
-            for line in node.toxml().splitlines():
-                yield '<pre>%s</pre>' % line
-        else:
-            yield node.toxml()
+    def get_source_lexer(self, filename, data):
+        return TextLexer()
diff --git a/reviewboard/reviews/ui/text.py b/reviewboard/reviews/ui/text.py
index b0fe1425eb30332906a7b5dd4c9986bbd78c5657..dc48ab1bf4ebf9993e632e7c8c7e4bf1226f5805 100644
--- a/reviewboard/reviews/ui/text.py
+++ b/reviewboard/reviews/ui/text.py
@@ -91,11 +91,7 @@ class TextBasedReviewUI(FileAttachmentReviewUI):
         data = self.obj.file.read()
         self.obj.file.close()
 
-        try:
-            lexer = guess_lexer_for_filename(self.obj.filename, data)
-        except ClassNotFound:
-            lexer = TextLexer()
-
+        lexer = self.get_source_lexer(self.obj.filename, data)
         lines = highlight(data, lexer, NoWrapperHtmlFormatter()).splitlines()
 
         return [
@@ -103,6 +99,19 @@ class TextBasedReviewUI(FileAttachmentReviewUI):
             for line in lines
         ]
 
+    def get_source_lexer(self, filename, data):
+        """Returns the lexer that should be used for the text.
+
+        By default, this will attempt to guess the lexer based on the
+        filename, falling back to a plain-text lexer.
+
+        Subclasses can override this to choose a more specific lexer.
+        """
+        try:
+            return guess_lexer_for_filename(filename, data)
+        except ClassNotFound:
+            return TextLexer()
+
     def generate_render(self):
         """Generates a render of the text.
 
