diff --git a/app/config/settings.py b/app/config/settings.py index 8e06ba64a0..7310eeba90 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -795,7 +795,11 @@ def get_private_ip(): MARKDOWNX_MARKDOWNIFY_FUNCTION = ( "grandchallenge.core.templatetags.bleach.md2html" ) -MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS = {} +MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS = { + "markdown.extensions.codehilite": { + "wrapcode": False, + } +} MARKDOWNX_IMAGE_MAX_SIZE = {"size": (2000, 0), "quality": 90} MARKDOWNX_EDITOR_RESIZABLE = "False" diff --git a/app/grandchallenge/core/static/css/base.scss b/app/grandchallenge/core/static/css/base.scss index f9a8e6bd46..c8fe073f46 100644 --- a/app/grandchallenge/core/static/css/base.scss +++ b/app/grandchallenge/core/static/css/base.scss @@ -50,3 +50,7 @@ blockquote { border-left: $spacer * .25 solid $primary; color: $primary; } + +div.codehilite { + margin-bottom: $paragraph-margin-bottom; +} diff --git a/app/grandchallenge/core/utils/markdown.py b/app/grandchallenge/core/utils/markdown.py index 98b9d94176..28df5d6c11 100644 --- a/app/grandchallenge/core/utils/markdown.py +++ b/app/grandchallenge/core/utils/markdown.py @@ -1,61 +1,34 @@ -from xml.etree import ElementTree - -from bs4 import BeautifulSoup, Tag +from bs4 import BeautifulSoup from markdown import Extension +from markdown.postprocessors import Postprocessor from markdown.treeprocessors import Treeprocessor class BS4Extension(Extension): def extendMarkdown(self, md): # noqa: N802 - md.registerExtension(self) - md.treeprocessors.register(BS4Treeprocessor(md), "bs4_extension", 0) - - -class BS4Treeprocessor(Treeprocessor): - def run(self, root): - el_class_dict = { - "img": "img-fluid", - "blockquote": "blockquote", - "table": "table table-hover table-borderless", - "thead": "thead-light", - "code": "codehilite", - } + md.postprocessors.register(BS4Postprocessor(md), "bs4_extension", 0) - for el in root.iter(): - if el.tag in el_class_dict: - self.set_css_class( - element=el, class_name=el_class_dict[el.tag] - ) - - for i, html_block in enumerate(self.md.htmlStash.rawHtmlBlocks): - bs4block = BeautifulSoup(html_block, "html.parser") - - for tag, tag_class in el_class_dict.items(): - for el in bs4block.find_all(tag): - self.set_css_class(element=el, class_name=tag_class) - self.md.htmlStash.rawHtmlBlocks[i] = str(bs4block) - @staticmethod - def set_css_class(*, element, class_name): - if isinstance(element, ElementTree.Element): - current_class = element.attrib.get("class", "") +class BS4Postprocessor(Postprocessor): + def run(self, text): + soup = BeautifulSoup(text, "html.parser") - if class_name not in current_class: - new_class = f"{current_class} {class_name}".strip() - element.set("class", new_class) - - elif isinstance(element, Tag): - if "class" not in element.attrs: - element.attrs["class"] = [] - - current_class = element["class"] + class_map = { + "img": ["img-fluid"], + "blockquote": ["blockquote"], + "table": ["table", "table-hover", "table-borderless"], + "thead": ["thead-light"], + "code": ["codehilite"], + } - for name in class_name.split(" "): - if class_name not in current_class: - current_class.append(name) + for element in soup.find_all([*class_map.keys()]): + classes = element.get("class", []) + for new_class in class_map[element.name]: + if new_class not in classes: + classes.append(new_class) + element["class"] = classes - else: - raise TypeError("Unsupported element") + return str(soup) class LinkBlankTargetExtension(Extension): diff --git a/app/tests/core_tests/test_markdown.py b/app/tests/core_tests/test_markdown.py index 69fa3fca9d..c04275ba09 100644 --- a/app/tests/core_tests/test_markdown.py +++ b/app/tests/core_tests/test_markdown.py @@ -1,11 +1,9 @@ import textwrap import pytest -from django.conf import settings from markdown import markdown from grandchallenge.core.templatetags.bleach import md2html -from grandchallenge.core.utils.markdown import BS4Treeprocessor @pytest.mark.parametrize( @@ -55,9 +53,9 @@ def test_function(): -
def test_function():
+ def test_function():
pass
-
"""
+
-Quote Me Existing Class
def test_function():
+ def test_function():
pass
-
-
+
no class
existing class
Delete me
CH3CH2OH texta subscript
<script>alert("foo")</script>
', + ), + ( + "[![](http://minio.localhost:9000/grand-challenge-public/i/2024/08/06/77c8d999-c22b-4983-8558-8e1fa364cd2c.jpg)](https://google.com)", + '', + ), ), ) def test_markdown_rendering(markdown_with_html, expected_output): @@ -202,54 +203,44 @@ def test_markdown_rendering(markdown_with_html, expected_output): @pytest.mark.parametrize( - "markdown_with_html, expected_output", - ( - ( - """ - [![](http://minio.localhost:9000/grand-challenge-public/i/2024/08/06/77c8d999-c22b-4983-8558-8e1fa364cd2c.jpg)](https://google.com)""", - """""", + "html, expected_output", + [ + ( # Unaffected element + "\n', ), - ( - """ - [![](http://minio.localhost:9000/grand-challenge-public/i/2024/08/06/77c8d999-c22b-4983-8558-8e1fa364cd2c.jpg)](https://google.com)""", - """""", + ( # Mixed content + "> Markdown Content\n" + "Content
\n
HTML Content", + '
\n\nMarkdown Content
\n
HTML Content', ), - ( - """ - [![](http://minio.localhost:9000/grand-challenge-public/i/2024/08/06/77c8d999-c22b-4983-8558-8e1fa364cd2c.jpg)](https://google.com)""", - """""", + ( # Empty class + '
Content', + '
Content', ), - ( - """ - [![](http://minio.localhost:9000/grand-challenge-public/i/2024/08/06/77c8d999-c22b-4983-8558-8e1fa364cd2c.jpg)](https://google.com)""", - """""", + ( # Existing class + '
Content', + '
Content', ), - ), + ( # Extension class already present + '
Content', + '
Content', + ), + ( # Existing class + extension class + '
Content', + '
Content', + ), + ], ) -def test_setting_class_to_html_img_within_markdown( - markdown_with_html, expected_output -): +def test_extend_html_tag_classes(html, expected_output, settings): output = markdown( - text=markdown_with_html, + text=html, extensions=settings.MARKDOWNX_MARKDOWN_EXTENSIONS, extension_configs=settings.MARKDOWNX_MARKDOWN_EXTENSION_CONFIGS, ) assert output == expected_output - - -def test_tree_processor_set_css_class_type_error(): - with pytest.raises(TypeError): - BS4Treeprocessor.set_css_class( - element="element", class_name="img-fluid" - ) diff --git a/app/tests/pages_tests/test_pages.py b/app/tests/pages_tests/test_pages.py index 7b390ae788..09931fdae7 100644 --- a/app/tests/pages_tests/test_pages.py +++ b/app/tests/pages_tests/test_pages.py @@ -107,8 +107,8 @@ def test_page_create(client, two_challenge_sets): response = get_view_for_user(url=response.url, client=client) assert response.status_code == 200 assert ( - '