Add syntax highlighting for fenced code blocks in markdown artifacts

Enables codehilite on the markdown renderer (Pygments, 'friendly' style,
vendored as static/themes/pygments.css) so fenced code blocks get real
syntax highlighting with zero client-side JS - matching the "themes are
CSS-only" stance in framework_artifact_rendering.md. Verified live against
the running dev instance: a fenced python block renders with highlighted
keyword/string/function spans.

This was Gap 2 from planning/2026-09-02-artifact_kind_gaps_for_frequent_agent_output.md,
picked as the first fix since it needed no new kind or design decision -
just enabling an existing markdown extension.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
peter 2026-09-02 14:39:02 +01:00
parent d9fc28aec0
commit af9ecf28a9
5 changed files with 102 additions and 2 deletions

View file

@ -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"""<!doctype html>
<html>
<head>
@ -602,6 +612,7 @@ def wrap_html(body, title, theme, request):
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<link rel="stylesheet" href="{stylesheet}">
<link rel="stylesheet" href="{pygments_stylesheet}">
</head>
<body class="artifact">
{body}

View file

@ -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 |

View file

@ -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

View file

@ -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 */

View file

@ -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('<span class="k">def</span>', stored_html)
self.assertIn('href="http://testserver/static/themes/pygments.css"', stored_html)