Markdown

chameleon-rs can convert Markdown to HTML while it renders, with a markdown: expression:

<article>${markdown: episode.notes}</article>
<div tal:content="markdown: comment.text"></div>

It’s opt-in: Chameleon has nothing like it, so you turn it on with configure() and use it in new templates. The converter is built into chameleon-rs (comrak, in Rust), so there’s no Markdown package to install.

Turn it on

import chameleon_rs
from chameleon_rs import PageTemplate

chameleon_rs.configure(markdown=True)

page = PageTemplate('<article>${markdown: notes}</article>')
print(page(notes='# Show notes\n\nWe talked about **templates** and `Rust`.'))
<article><h1>Show notes</h1>
<p>We talked about <strong>templates</strong> and <code>Rust</code>.</p>
</article>

configure(markdown=True) registers the markdown: prefix with the defaults. Call it once at startup, before your templates compile: a template takes the Markdown settings in effect when it’s compiled.

What it supports

CommonMark plus the GitHub extensions: tables, strikethrough, task lists, autolinks for bare URLs, and footnotes.

notes = """\
| Engine | Speed |
|---|---|
| chameleon-rs | ~~slow~~ fast |

- [x] Ship it, see https://example.com
"""
print(page(notes=notes))
<article><table>
<thead>
<tr>
<th>Engine</th>
<th>Speed</th>
</tr>
</thead>
<tbody>
<tr>
<td>chameleon-rs</td>
<td><del>slow</del> fast</td>
</tr>
</tbody>
</table>
<ul>
<li><input type="checkbox" checked="" disabled="" /> Ship it, see <a href="https://example.com">https://example.com</a></li>
</ul>
</article>

Fenced code blocks get a language-… class, so a client-side highlighter such as highlight.js or Prism can color them:

print(page(notes='```python\nprint("hi")\n```'))
<article><pre><code class="language-python">print(&quot;hi&quot;)
</code></pre>
</article>

Values

  • The text after markdown: is an ordinary expression, so a | fallback works: markdown: episode.notes | 'No notes yet.'. As everywhere in TALES, the fallback is used when the expression raises (a missing attribute, say), not when its value is None.
  • None and '' render nothing. Any other non-string value is converted with str() first.
  • default works as for any expression: tal:content="markdown: page.notes | default" keeps the element’s own content when page.notes is missing.
  • The result is markup, like structure:, so it isn’t escaped again in element content.
  • In an attribute value (alt="${markdown: x}", tal:attributes), the result is escaped like any string. Markdown’s HTML has quoted attributes of its own (<a href="...">), which would otherwise close the surrounding attribute and let the text add new ones, such as an event handler. Use Markdown in element content; ${structure: markdown: x} writes it raw anyway.
print(PageTemplate('<div>${markdown: missing.notes | "*Coming soon*"}</div>')())
print(PageTemplate('<p>[${markdown: notes}]</p>')(notes=None))
<div><p><em>Coming soon</em></p>
</div>
<p>[]</p>

Safe by default

Raw HTML in the Markdown is shown as text, not passed through. That’s the right default for text you don’t fully control, such as user comments:

print(page(notes='Nice post! <script>alert("hi")</script>'))
<article><p>Nice post! &lt;script&gt;alert(&quot;hi&quot;)&lt;/script&gt;</p>
</article>

Links with a javascript:, vbscript:, file: or data: URL (other than an image) lose the URL too.

For content you trust, such as your own articles, allow raw HTML in a separate profile (see below). raw_html: 'allow' lets those URLs through as well.

“Safe” here is about markup and requests, not CPU time. Some inputs are slow to convert: a table with thousands of columns can take seconds. If anyone can post the text, limit its length when you accept it, and consider turning tables off for that profile ('extensions': {'table': False}).

Profiles: one prefix per kind of content

Different content needs different settings: your own articles may allow HTML, while comments shouldn’t even load images. Each profile is its own expression prefix:

chameleon_rs.configure(markdown={
    'markdown': {},                                     # the defaults
    'md-trusted': {'raw_html': 'allow', 'heading_ids': True},
    'md-comment': {'images': False},
    'md-inline': {'inline': True},
})

page = PageTemplate("""\
<h1>${md-inline: post.title}</h1>
<article>${md-trusted: post.body}</article>
<div tal:repeat="c post.comments">${md-comment: c}</div>
""")


class Post:
    title = 'Hello *world*'
    body = '## Intro\n\nSome <abbr title="HyperText Markup Language">HTML</abbr>.'
    comments = ['Look: ![tracker](https://example.com/pixel.png)']


print(page(post=Post()))
<h1>Hello <em>world</em></h1>
<article><h2 id="intro">Intro<a href="#intro" aria-label="Link to heading 'Intro'" data-heading-content="Intro" class="anchor"></a></h2>
<p>Some <abbr title="HyperText Markup Language">HTML</abbr>.</p>
</article>
<div><p>Look: tracker</p>
</div>

A profile name is a prefix, so it uses lowercase letters, digits, - and _, and can’t be one of Chameleon’s own (python, string, structure, …). A template class that defines a prefix itself in expression_types keeps its own.

Profile options

Option Default Meaning
raw_html 'escape' 'escape' shows raw HTML as text. 'allow' passes it through: trusted content only.
images True False renders an image as its alt text, so the text can’t make the page load a remote image.
inline False Drops the <p> around output that’s a single paragraph, for titles and one-liners in element content (<h1>${md-inline: title}</h1>).
heading_ids False True gives headings GitHub-style ids and anchor links. A string adds a prefix to each id, such as 'doc-' (letters, digits, - and _).
classes {} A class attribute per element, such as {'table': 'table is-striped', 'pre': 'code'}. Elements: p, h1–h6, blockquote, ul, ol, li, pre, code, table, a, img, hr. See the notes below.
extensions all on Switch GFM extensions off: {'table': False}. Keys: table, strikethrough, tasklist, autolink, footnotes.
chameleon_rs.configure(markdown={'markdown': {'classes': {'table': 'table is-striped', 'blockquote': 'note'}}})
print(PageTemplate('${markdown: s}')(s='> Tip\n\n| a |\n|---|\n| 1 |'))
<blockquote class="note">
<p>Tip</p>
</blockquote>
<table class="table is-striped">
<thead>
<tr>
<th>a</th>
</tr>
</thead>
<tbody>
<tr>
<td>1</td>
</tr>
</tbody>
</table>

Notes on classes:

  • Only elements the Markdown produces get a class. Raw HTML in the text never does.
  • code is inline code. A fenced block’s <code> keeps its language-… class; style it through pre.
  • The footnote list, footnote references and back-links, and heading anchor links don’t get the ol, li and a classes. Footnote paragraphs do get the p class.
  • A paragraph inside a tight list (no blank lines between items) has no <p> tag, so it gets no p class.

Each conversion numbers its heading ids on its own, so two markdown: blocks with heading_ids on one page can repeat an id. Give each block a profile with its own prefix.

A bad setting raises ValueError (or TypeError for a value of the wrong type) when you call configure(), and changes nothing.

Speed and the cache

The converter is fast: a 5 KB document with tables, lists, code and footnotes converts in about 55 µs, about 26x faster than mistune with the same extensions.

On top of that, chameleon-rs keeps recent conversions in a cache shared by all threads: the same text with the same profile isn’t converted twice. A cache hit costs about 0.3 µs for a 5 KB document. The cache holds 1,024 conversions by default, and at most 64 MB of text and HTML whatever the count. Texts over 64 KB are converted every time rather than cached:

chameleon_rs.configure(markdown_cache=4096)   # entries; 0 turns the cache off

Changing the Markdown settings empties the cache.

Async renders

render_async works with markdown:, with one rule shared by every expression type: only an expression’s final value is awaited, not a value inside it. Load the Markdown text in your view before rendering, rather than passing a coroutine for markdown: to convert.