diff --git a/artifacts/views.py b/artifacts/views.py
index f9eaf4b..faab737 100644
--- a/artifacts/views.py
+++ b/artifacts/views.py
@@ -502,7 +502,16 @@ def create_text_artifact(data, request, user):
if kind == Artifact.HTML_FULL:
rendered = content
elif kind == Artifact.MARKDOWN:
- rendered = wrap_html(markdown.markdown(content, extensions=["extra", "sane_lists"]), title, theme, request)
+ rendered = wrap_html(
+ markdown.markdown(
+ content,
+ extensions=["extra", "sane_lists", "codehilite"],
+ extension_configs={"codehilite": {"guess_lang": False}},
+ ),
+ title,
+ theme,
+ request,
+ )
(target_dir / f"{artifact_id}.md").write_text(content, encoding="utf-8")
elif kind == Artifact.DECK:
rendered = wrap_deck(content, title, request)
@@ -595,6 +604,7 @@ def create_binary_artifact(data, user):
def wrap_html(body, title, theme, request):
stylesheet = request.build_absolute_uri(f"/static/themes/{theme}.css")
+ pygments_stylesheet = request.build_absolute_uri("/static/themes/pygments.css")
return f"""
@@ -602,6 +612,7 @@ def wrap_html(body, title, theme, request):
{html.escape(title)}
+
{body}
diff --git a/docs/api.md b/docs/api.md
index f2a986b..5001d8a 100644
--- a/docs/api.md
+++ b/docs/api.md
@@ -30,7 +30,7 @@ Currently supported kinds:
|---|---|---|---|
| `html_fragment` | JSON text | Wrapped with a Daybook theme | No |
| `html_full` | JSON text | Stored as complete HTML | Yes |
-| `markdown` | JSON text | Rendered to HTML and original `.md` is preserved | Yes |
+| `markdown` | JSON text | Rendered to HTML (fenced code blocks get syntax highlighting) and original `.md` is preserved | Yes |
| `deck` | JSON text | Wrapped as a simple HTML slide/deck page | No |
| `image` | Multipart file | Browser image preview | No |
| `pdf` | Multipart file | Browser PDF preview | No |
diff --git a/requirements.txt b/requirements.txt
index 696bd2b..9da4882 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,5 +1,6 @@
Django>=5.0,<6.0
djangorestframework>=3.15,<4.0
Markdown>=3.6,<4.0
+Pygments>=2.17,<3.0
gunicorn>=23.0,<24.0
whitenoise>=6.7,<7.0
diff --git a/static/themes/pygments.css b/static/themes/pygments.css
new file mode 100644
index 0000000..4a1e617
--- /dev/null
+++ b/static/themes/pygments.css
@@ -0,0 +1,75 @@
+pre { line-height: 125%; }
+td.linenos .normal { color: #666666; background-color: transparent; padding-left: 5px; padding-right: 5px; }
+span.linenos { color: #666666; background-color: transparent; padding-left: 5px; padding-right: 5px; }
+td.linenos .special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
+span.linenos.special { color: #000000; background-color: #ffffc0; padding-left: 5px; padding-right: 5px; }
+.codehilite .hll { background-color: #ffffcc }
+.codehilite { background: #f0f0f0; }
+.codehilite .c { color: #60A0B0; font-style: italic } /* Comment */
+.codehilite .err { border: 1px solid #F00 } /* Error */
+.codehilite .k { color: #007020; font-weight: bold } /* Keyword */
+.codehilite .o { color: #666 } /* Operator */
+.codehilite .ch { color: #60A0B0; font-style: italic } /* Comment.Hashbang */
+.codehilite .cm { color: #60A0B0; font-style: italic } /* Comment.Multiline */
+.codehilite .cp { color: #007020 } /* Comment.Preproc */
+.codehilite .cpf { color: #60A0B0; font-style: italic } /* Comment.PreprocFile */
+.codehilite .c1 { color: #60A0B0; font-style: italic } /* Comment.Single */
+.codehilite .cs { color: #60A0B0; background-color: #FFF0F0 } /* Comment.Special */
+.codehilite .gd { color: #A00000 } /* Generic.Deleted */
+.codehilite .ge { font-style: italic } /* Generic.Emph */
+.codehilite .ges { font-weight: bold; font-style: italic } /* Generic.EmphStrong */
+.codehilite .gr { color: #F00 } /* Generic.Error */
+.codehilite .gh { color: #000080; font-weight: bold } /* Generic.Heading */
+.codehilite .gi { color: #00A000 } /* Generic.Inserted */
+.codehilite .go { color: #888 } /* Generic.Output */
+.codehilite .gp { color: #C65D09; font-weight: bold } /* Generic.Prompt */
+.codehilite .gs { font-weight: bold } /* Generic.Strong */
+.codehilite .gu { color: #800080; font-weight: bold } /* Generic.Subheading */
+.codehilite .gt { color: #04D } /* Generic.Traceback */
+.codehilite .kc { color: #007020; font-weight: bold } /* Keyword.Constant */
+.codehilite .kd { color: #007020; font-weight: bold } /* Keyword.Declaration */
+.codehilite .kn { color: #007020; font-weight: bold } /* Keyword.Namespace */
+.codehilite .kp { color: #007020 } /* Keyword.Pseudo */
+.codehilite .kr { color: #007020; font-weight: bold } /* Keyword.Reserved */
+.codehilite .kt { color: #902000 } /* Keyword.Type */
+.codehilite .m { color: #40A070 } /* Literal.Number */
+.codehilite .s { color: #4070A0 } /* Literal.String */
+.codehilite .na { color: #4070A0 } /* Name.Attribute */
+.codehilite .nb { color: #007020 } /* Name.Builtin */
+.codehilite .nc { color: #0E84B5; font-weight: bold } /* Name.Class */
+.codehilite .no { color: #60ADD5 } /* Name.Constant */
+.codehilite .nd { color: #555; font-weight: bold } /* Name.Decorator */
+.codehilite .ni { color: #D55537; font-weight: bold } /* Name.Entity */
+.codehilite .ne { color: #007020 } /* Name.Exception */
+.codehilite .nf { color: #06287E } /* Name.Function */
+.codehilite .nl { color: #002070; font-weight: bold } /* Name.Label */
+.codehilite .nn { color: #0E84B5; font-weight: bold } /* Name.Namespace */
+.codehilite .nt { color: #062873; font-weight: bold } /* Name.Tag */
+.codehilite .nv { color: #BB60D5 } /* Name.Variable */
+.codehilite .ow { color: #007020; font-weight: bold } /* Operator.Word */
+.codehilite .w { color: #BBB } /* Text.Whitespace */
+.codehilite .mb { color: #40A070 } /* Literal.Number.Bin */
+.codehilite .mf { color: #40A070 } /* Literal.Number.Float */
+.codehilite .mh { color: #40A070 } /* Literal.Number.Hex */
+.codehilite .mi { color: #40A070 } /* Literal.Number.Integer */
+.codehilite .mo { color: #40A070 } /* Literal.Number.Oct */
+.codehilite .sa { color: #4070A0 } /* Literal.String.Affix */
+.codehilite .sb { color: #4070A0 } /* Literal.String.Backtick */
+.codehilite .sc { color: #4070A0 } /* Literal.String.Char */
+.codehilite .dl { color: #4070A0 } /* Literal.String.Delimiter */
+.codehilite .sd { color: #4070A0; font-style: italic } /* Literal.String.Doc */
+.codehilite .s2 { color: #4070A0 } /* Literal.String.Double */
+.codehilite .se { color: #4070A0; font-weight: bold } /* Literal.String.Escape */
+.codehilite .sh { color: #4070A0 } /* Literal.String.Heredoc */
+.codehilite .si { color: #70A0D0; font-style: italic } /* Literal.String.Interpol */
+.codehilite .sx { color: #C65D09 } /* Literal.String.Other */
+.codehilite .sr { color: #235388 } /* Literal.String.Regex */
+.codehilite .s1 { color: #4070A0 } /* Literal.String.Single */
+.codehilite .ss { color: #517918 } /* Literal.String.Symbol */
+.codehilite .bp { color: #007020 } /* Name.Builtin.Pseudo */
+.codehilite .fm { color: #06287E } /* Name.Function.Magic */
+.codehilite .vc { color: #BB60D5 } /* Name.Variable.Class */
+.codehilite .vg { color: #BB60D5 } /* Name.Variable.Global */
+.codehilite .vi { color: #BB60D5 } /* Name.Variable.Instance */
+.codehilite .vm { color: #BB60D5 } /* Name.Variable.Magic */
+.codehilite .il { color: #40A070 } /* Literal.Number.Integer.Long */
diff --git a/tests/api/test_artifacts.py b/tests/api/test_artifacts.py
index 333b527..eac2119 100644
--- a/tests/api/test_artifacts.py
+++ b/tests/api/test_artifacts.py
@@ -356,3 +356,16 @@ class ArtifactAPITestCase(APITestCase):
)
self.assertEqual(response.status_code, 400, field)
self.assertIn(field, response.data["detail"])
+
+ # -- syntax highlighting -------------------------------------------------
+
+ def test_markdown_fenced_code_block_gets_syntax_highlighted(self):
+ response = self.create_artifact(
+ content="# Test\n\n```python\ndef hello():\n return \"world\"\n```"
+ )
+ artifact = Artifact.objects.get(pk=response.data["id"])
+ stored_html = (self.artifacts_root / artifact.path).read_text(encoding="utf-8")
+
+ self.assertIn('class="codehilite"', stored_html)
+ self.assertIn('def', stored_html)
+ self.assertIn('href="http://testserver/static/themes/pygments.css"', stored_html)