Markdown
StaticFiles renders Markdown as HTML and evaluates any Jinja expressions before converting the Markdown. A Markdown file can provide the body of a standalone page or be included within a template through include_markdown(). Templates can also contain Markdown directly inside a {% markdown %} block, which follows the same rendering process.
Markdown pages
A .md or .markdown file in a served directory is available as an HTML page with the text/html media type. Frontmatter can supply page variables and name a layout, but a file containing only Markdown also works. Direct file URLs and extensionless URLs resolve with or without html=True.
Configure StaticFiles with a loader for the site directory, as shown in Basic usage. Then create site/about.md with the following frontmatter and body. The title becomes a page variable, and layout names the HTML template created in the next section:
---
layout: base.html.jinja
title: About us
nav_order: 2
---
# {{ page.title }}
Hello **{{ request.query_params.get('name', 'friend') }}**.
Layouts
A layout wraps the converted Markdown in a Jinja template so several pages can share an HTML structure. The layout value is a template path relative to the Jinja loader root, such as base.html.jinja or layouts/base.html.jinja. For the About page above, create site/base.html.jinja:
<!DOCTYPE html>
<html>
<head><title>{{ page.title }}</title></head>
<body><main>{{ content }}</main></body>
</html>
Both GET /about and GET /about.md return the page inside this layout. The layout receives the converted HTML as content, marked safe so {{ content }} inserts it without escaping.
Layouts have access to page, the page's page_context variables, and template globals. They can use shortcodes, fragment tags, and {% extends %} like any other Jinja template.
Omit layout to serve the converted HTML without a wrapper. A missing layout template raises a ValueError that names both the Markdown file and the requested layout.
For example, site/status.md needs only a Markdown body:
# Service status
All systems are **operational**.
GET /status returns:
<h1>Service status</h1>
<p>All systems are <strong>operational</strong>.</p>
Frontmatter
Define page variables in a YAML block between --- lines at the start of the file. The opening fence must be on the first line; a later --- stays in the Markdown body.
The frontmatter is available as the page dictionary in both the body and the layout. For example, page.title reads title, and an undefined key renders as an empty string. Files with no frontmatter or an empty block get an empty page dictionary.
Frontmatter can contain lists and nested mappings. In site/team.md, loop over a list of people:
---
layout: base.html.jinja
title: Our team
team:
- name: Ada
role: Engineering
- name: Sam
role: Design
---
# {{ page.title }}
{% for person in page.team %}
- **{{ person.name }}** — {{ person.role }}
{% endfor %}
GET /team shows the heading and a two-item list inside base.html.jinja.
An unclosed fence, invalid YAML, or a nonempty block that does not contain a mapping raises a ValueError naming the file. The layout value must be a string or YAML null.
Shortcodes in Markdown
Shortcodes let a Markdown page reuse HTML components through Jinja tags. In this example, StaticFiles discovers site/shortcodes/station_card.html as a {% station_card %} tag that displays a station name and description. Create the file before constructing StaticFiles:
<aside class="station-card">
<h2>{{ name }}</h2>
<p>{{ description }}</p>
</aside>
Call the tag from site/weather.md, passing values from frontmatter:
---
layout: base.html.jinja
title: Local weather
station:
name: Boston
description: Observations from the harbor.
---
# {{ page.title }}
{% station_card name=page.station.name description=page.station.description %}
Read the **latest observations** below.
GET /weather renders the heading, the station card's HTML, and the final Markdown paragraph inside base.html.jinja. Leave blank lines around the shortcode so its HTML and the surrounding Markdown render as separate blocks. See Shortcodes and fragments in Markdown for paired tags and spacing rules.
SQL in Markdown
Declare named queries with sql fences or {% sql %} blocks, then run them with fetch().
With a query runner configured for a weather database containing a stations table, create site/stations.md:
---
layout: base.html.jinja
title: Weather stations
---
```sql stations from weather
SELECT name FROM stations WHERE country = :country ORDER BY name
```
# {{ page.title }}
{% for station in fetch(queries.stations) %}
- {{ station.name }}
{% else %}
No stations found.
{% endfor %}
GET /stations?country=US binds US to :country and lists the matching station names. The SQL declaration produces no visible output; the query runs when fetch() is called.
To use a Jinja SQL block in the same Markdown file, replace the sql fence with:
{% sql stations from weather %}
SELECT name FROM stations WHERE country = :country ORDER BY name
{% endsql %}
The fetch(queries.stations) loop works with either form. See Named SQL queries for query runners and the other fetch helpers.
Markdown bodies also support Jinja filters, fragment tags, and the page_context variables for the requested URL.
Jinja renders before the Markdown conversion. Autoescape escapes HTML characters in variable values; Markdown syntax in those values can still affect formatting.
Rendering Markdown inside a template
The include_markdown() helper inserts the converted body of a Markdown file into a template, which lets you keep written content in a separate file while the template controls the surrounding HTML. For Markdown written directly in the template, use a Markdown block.
An included file inherits the calling template's context, including request and queries. Its frontmatter becomes page during the include, and its layout is ignored so only the converted body is inserted.
Given site/intro.md:
---
layout: base.html.jinja
title: Welcome
---
# {{ page.title }}
Hello **{{ request.query_params.get('name', 'friend') }}**.
Include it from site/index.html.jinja:
<article>{{ include_markdown('intro.md') }}</article>
The include uses intro.md's page.title and returns only its body:
<article><h1>Welcome</h1>
<p>Hello <strong>friend</strong>.</p>
</article>
Requesting /intro directly renders the same body inside base.html.jinja.
markdown-it-py renders the Markdown. Tables, strikethrough, footnotes, definition lists, task lists, admonitions, and attribute lists are enabled.
A Markdown block in a template
A {% markdown %} block writes Markdown where it belongs, inside the HTML, with no separate file:
<article class="prose">
{% markdown %}
# {{ title }}
Text with **bold** and a [link]({{ url_for('/about') }}).
- one
- two
{% endmarkdown %}
</article>
The body is Jinja first and Markdown second. It renders like any other template body — variables, filters, shortcodes, fragment tags, {% sql %} — and the result becomes HTML.
Two things the block handles for you:
- Indentation comes off. The block strips the common indentation of its lines before the conversion, so a block laid out to match the HTML around it is not read as a code block. Indent further than the block's own margin, by four spaces or more, and you get a code block as usual.
- HTML characters in values are escaped. Autoescape runs before Markdown conversion, so Markdown syntax in
{{ comment.body }}can still affect formatting.
A {% sql %} tag inside a block declares into the page's QuerySet, the same as one in an included Markdown file. The rest of the page then fetches it by name.
The tag also works outside StaticFiles. Build it with markdown_extension and give it the renderer to use:
from jinja2 import Environment, FileSystemLoader
from markdown_it import MarkdownIt
from starlette_templates.staticfiles import markdown_extension
env = Environment(
loader=FileSystemLoader("templates"),
autoescape=True,
extensions=[markdown_extension(MarkdownIt("commonmark"))],
)
Shortcodes and fragments in Markdown
include_markdown() renders the file through the same environment as a page, so a Markdown file writes the same tags a .jinja page writes — shortcodes, fragments, and {% sql %}. Jinja runs first, then markdown-it converts the result. Raw HTML is enabled, so the HTML a tag produces passes through untouched. The same holds inside a {% markdown %} block.
{% youtube id="dQw4w9WgXcQ" %}
{% note kind="warning" %}
Do not feed the llamas after midnight.
{% endnote %}
Total: {% cart_total %}
<iframe src="https://youtube.com/embed/dQw4w9WgXcQ"></iframe>
<div class="note note-warning">
Do not feed the llamas after midnight.
</div>
<p>Total: <span id="cart_total">42</span></p>
A fragment keeps its id through the Markdown pass, so a later HX-Trigger still swaps it out of band. Three rules follow from the order of the two passes:
- Do not indent a tag by four spaces. The tag expands, and markdown-it then reads the HTML as a code block and escapes it.
- Put a blank line before and after a block-level tag, so its output starts an HTML block of its own.
- Markdown inside a paired shortcode's body is only partly converted. The opening tag starts an HTML block, so the lines that follow it stay literal until the next blank line. Write the body as HTML, or leave a blank line after the opening tag.