"""Markdown rendering. Raw HTML (including inline scripts) is passed through unfiltered: the single author is trusted. Extensions: tables and strikethrough (from the "default" preset), footnotes, definition lists, task lists, brace-attributes (`{.class width=300}` on any element, images in particular), admonitions (``!!! note Title`` with an indented body — note/tip/warning/etc., the title optional) and GitHub-style alerts (``> [!NOTE]`` / TIP / IMPORTANT / WARNING / CAUTION, rendered in the same callout styling). ``::: name`` opens a generic container rendered as ``
wrapper,
and the stylesheet is scoped to "pre code" to match.
"""
try:
lexer = get_lexer_by_name(lang)
except ClassNotFound:
return "" # fall back to default
return highlight(text, lexer, _formatter)
def _fence_rule(
self: RendererHTML,
tokens,
idx: int,
options,
env: dict,
) -> str:
"""Render a fenced code block.
Like the default fence rule, but block attributes go on the —
the block element — instead of the , which keeps only the
language class. Attributes are accepted both pandoc-style on the
info line (```{.python .wide #id key=val} — the first class is the
language when no bare language word precedes the braces) and as a
trailing `{...}` line applied by _block_attrs. This is what makes
e.g. `{.wide}` or `{style="..."}` style the block itself.
"""
token = tokens[idx]
info = token.info.strip() if token.info else ""
lang, _, brace = info.partition("{")
lang = lang.split(maxsplit=1)[0] if lang.strip() else ""
if brace:
try:
_, attrs = parse_attrs("{" + brace)
except ParseError:
attrs = {}
classes = attrs.pop("class", "").split()
if not lang and classes:
lang = classes.pop(0)
if classes:
_apply_attrs(token, {"class": " ".join(classes)})
_apply_attrs(token, attrs)
highlighted = _highlight(token.content, lang, "") or escapeHtml(token.content)
code_class = f' class="{options.langPrefix}{lang}"' if lang else ""
return (
f"{highlighted}
\n"
)
def _image_rule(
self: RendererHTML,
tokens,
idx: int,
options,
env: dict,
) -> str:
"""Render images, resolving relative srcs against the page path."""
token = tokens[idx]
src = token.attrs["src"]
if not src.startswith(("/", "http://", "https://", "data:")):
page = env.get("page_path", "")
token.attrs["src"] = f"/{page}/{src}" if page else f"/{src}"
token.attrs["alt"] = self.renderInlineAsText(token.children, options, env)
if len(tokens) == 1:
# The only inline content of its paragraph: render as a block
# figure, captioned when titled. (The wrapper is dropped by
# _unwrap_lone_figures below.) {.margin} positions the whole
# figure, so it moves from the img onto the figure wrapper — left
# on the img, the margin-breakout CSS would pull the image out of
# the figure (and mostly off-screen), leaving the caption behind.
classes = (token.attrs.get("class") or "").split()
figure_class = ""
if "margin" in classes:
classes.remove("margin")
if classes:
token.attrs["class"] = " ".join(classes)
else:
del token.attrs["class"]
figure_class = ' class="margin"'
img = self.renderToken(tokens, idx, options, env)
title = token.attrs.get("title")
caption = f"{escapeHtml(title)} " if title else ""
return f"{img}{caption} "
img = self.renderToken(tokens, idx, options, env)
# Inline with other content: a plain inline image.
return img
def _unwrap_lone_figures(state) -> None:
"""Drop the
wrapper around a lone image.
markdown-it wraps inline content in a paragraph, but our image rule
turns lone images into — a block element that is invalid
inside . Browsers hoist it out, leaving an empty paragraph whose
margins disturb the layout.
"""
tokens = state.tokens
for i, token in enumerate(tokens):
if token.type != "inline" or not token.children:
continue
# Attrs consumed out of the text (e.g. {style=...} space-separated
# on the image's own line) leave empty text tokens behind — strip
# them so the lone-image check is not thrown off by user styling.
children = [c for c in token.children if c.type != "text" or c.content]
if children:
token.children = children
[child] = children if len(children) == 1 else [None]
if (
child
and child.type == "image"
and tokens[i - 1].type == "paragraph_open"
and tokens[i + 1].type == "paragraph_close"
):
# A lone image becomes a (see _image_rule); block
# attrs on the paragraph (e.g. a trailing {.wide} line) move
# onto the image so they survive the unwrap.
_apply_attrs(child, tokens[i - 1].attrs or {})
tokens[i - 1].hidden = True
tokens[i + 1].hidden = True
def _tag_task_checkboxes(state) -> None:
"""Tag rendered task-list checkboxes with a stable index.
The public page and the editor preview use the index to identify which
`[ ]`/`[x]` marker in the Markdown source to toggle when a visitor
clicks the checkbox.
"""
index = 0
for token in state.tokens:
if token.type != "inline" or not token.children:
continue
for child in token.children:
if child.type == "html_inline" and 'type="checkbox"' in child.content:
child.content = child.content.replace(
'type="checkbox"',
f'data-task-index="{index}" type="checkbox"',
1,
)
index += 1
def _shorten_autolinks(state) -> None:
"""Hide the https:// scheme in the text of bare autolinked URLs.
GFM linkify sets the link text to the URL itself; only those links
(markup "autolink") are shortened. http:// and other schemes stay
visible, and manually labelled links keep whatever label was written.
"""
for token in state.tokens:
if token.type != "inline" or not token.children:
continue
for i, child in enumerate(token.children):
if child.type == "link_open" and child.markup == "autolink":
text = token.children[i + 1]
if text.type == "text" and text.content.startswith("https://"):
text.content = text.content.removeprefix("https://")
_CONTAINER_NAME_RE = re.compile(r"[a-zA-Z][\w-]*")
def _apply_attrs(token, attrs: dict) -> None:
"""Join/set parsed brace attributes (`{.class key=value}`) on a token."""
for key, value in attrs.items():
if key == "class":
token.attrJoin("class", value)
else:
token.attrSet(key, value)
def _container_validate(params: str, _markup: str) -> bool:
"""`::: name`, optionally followed by brace attrs (`::: aside {.right}`).
Pandoc-style nameless divs (`::: {.aside}`) are accepted too — the
attrs alone give the container its classes.
"""
name, _, rest = params.strip().partition(" ")
if name.startswith("{"):
name, rest = "", params.strip()
elif not _CONTAINER_NAME_RE.fullmatch(name):
return False
rest = rest.strip()
if not rest:
return bool(name) # a nameless container needs the attrs
try:
pos, _ = parse_attrs(rest)
except ParseError:
return False
# parse() stops at (returns the index of) the closing brace.
return pos == len(rest) - 1
def _container_attrs(state) -> None:
"""Apply `::: name {attrs}` classes to container tokens at parse time.
The container plugin's default render is a plain renderToken, so the
name and brace attributes must live on the token itself — and being a
core rule (rather than a render rule) lets the segmentation in
render() see the classes (the ::: nocols opt-out, {.wide}
containers).
"""
for token in state.tokens:
if token.type != "container_block_open":
continue
info = token.info.strip()
name, _, rest = info.partition(" ")
if name.startswith("{"):
name, rest = "", info
if name:
token.attrJoin("class", name)
if rest.strip():
_, attrs = parse_attrs(rest.strip())
_apply_attrs(token, attrs)
def _block_attrs(state) -> None:
"""Apply `{.class key=value}` on a block's last line to the block.
The inline attrs plugin only covers attributes right after an image,
code span or link; this extends the same brace syntax to whole blocks.
A paragraph takes them at the end of its last line, either directly
(a trailing `{.wide}` line, no blank line between) or space-separated
at the end of the text (`some text {.small}`) — a space means the
braces belong to the block, not to an image or link before them.
A lone `{...}` paragraph applies to the previous block instead (this
is how headings take attributes, since a heading's next line always
starts a new paragraph). Runs before the typographer so quotes inside
attributes stay straight.
"""
tokens = state.tokens
for i, token in enumerate(tokens):
if token.type != "inline" or not token.children:
continue
text = token.children[-1]
if text.type != "text":
continue
m = re.search(r"(\{[^{}]*\})\s*$", text.content)
if not m:
continue
start = m.start(1)
if start and not text.content[start - 1].isspace():
continue # glued to the text — literal, or inline attrs
try:
_, attrs = parse_attrs(m.group(1))
except ParseError:
continue
standalone = len(token.children) == 1
if not standalone and start == 0 and token.children[-2].type != "softbreak":
continue
# The target: the enclosing block for a trailing attrs line, or the
# previous same-level block for a standalone attrs paragraph —
# including self-contained blocks like code fences and
. Never
# a hidden token (tight-list paragraphs render no tag to hold the
# attributes) — in that case leave the text untouched instead of
# silently swallowing it.
own = i - 1 # standalone: the attrs paragraph's own opening token
j = i - 1
while j >= 0:
target = tokens[j]
if target.hidden:
pass
elif standalone:
if (
j != own
and target.level == tokens[own].level
and (
target.nesting == 1
or target.type in ("fence", "code_block", "hr")
)
):
break
elif target.nesting == 1:
break
j -= 1
if j < 0:
continue
_apply_attrs(tokens[j], attrs)
if standalone:
tokens[own].hidden = True
token.children = []
tokens[i + 1].hidden = True
elif start == 0:
del token.children[-2:]
else:
# Braces space-separated at the end of a text line: strip them
# (a whitespace-only remainder means they were on a line of
# their own after all — drop the softbreak too).
text.content = text.content[:start].rstrip()
if not text.content and token.children[-2].type == "softbreak":
del token.children[-2:]
#: Minimum number of in-body h1/h2 headings for section anchors to be
#: useful — shorter articles get no ids/self-links at all.
ANCHOR_MIN_HEADINGS = 3
def _heading_ids(state) -> None:
"""Anchor the in-body h1/h2 headings of long-enough articles.
The markdown body's own h1 and h2 headings get a slug id and their
text is wrapped in a self-link (````) so
section links are copyable by click or right-click — but only when the
body has at least ANCHOR_MIN_HEADINGS of them; shorter articles stay
anchor-free. The FIRST h1 is the article title: like the implicit
page-title h1 it gets no id, does not count toward the threshold, and
its self-link is ``href=""`` (back to the top of the page). An
author-set `{#id}` always wins; auto ids slugify the heading text
(python-slugify, mirroring the editor's slugify.js) and dedupe with
-2/-3 suffixes per render — unless env["anchor_ids"] presets them, as
render(anchors_from=...) does for translated pages so section URLs
stay in the original language. Headings that already contain a link are
``data-line`` records the heading's markdown source line (0-based, after
undoing the render(title=...) injection offset via ``env``) — the page
editor uses it for section pens and piecewise-linear scroll sync.
"""
tokens = state.tokens
line_offset = state.env.get("line_offset", 0)
def wrap(i: int, token, href: str) -> None:
inline = tokens[i + 1]
if not inline.children or any(c.type == "link_open" for c in inline.children):
return
anchor = Token("link_open", "a", 1)
anchor.attrs = {"href": href, "class": "anchor"}
inline.children = [anchor, *inline.children, Token("link_close", "a", -1)]
# The first in-body h1 is the title: href="" self-link, never an id.
# Only TOP-LEVEL headings participate — h1/h2 nested in ::: containers
# or asides (level > 0) get no anchors, data-lines or pens.
first_h1 = next(
(
i
for i, t in enumerate(tokens)
if t.type == "heading_open" and t.tag == "h1" and t.level == 0
),
None,
)
if first_h1 is not None:
wrap(first_h1, tokens[first_h1], "")
heads = [
(i, token)
for i, token in enumerate(tokens)
if token.type == "heading_open"
and token.tag in ("h1", "h2")
and token.level == 0
and i != first_h1
]
if len(heads) < ANCHOR_MIN_HEADINGS:
return
seen: set[str] = set()
preset = state.env.get("anchor_ids")
for k, (i, token) in enumerate(heads):
inline = tokens[i + 1]
hid = token.attrGet("id")
if not isinstance(hid, str) or not hid:
if preset is not None and k < len(preset):
# Translated render: the original language's slug, matched
# by heading position (a translation never adds, removes or
# reorders headings; a patched one that does falls back to
# slugging its own text past the end of the list).
base = preset[k]
else:
# Slug the visible text, not the raw markdown (`## [a](url)`).
text = "".join(
c.content
for c in inline.children
if c.type in ("text", "code_inline")
)
base = slugify(text) or "section"
hid, n = base, 2
while hid in seen:
hid = f"{base}-{n}"
n += 1
token.attrSet("id", hid)
seen.add(hid)
if token.map:
token.attrSet("data-line", str(max(0, token.map[0] - line_offset)))
wrap(i, token, f"#{hid}")
def anchor_ids(text: str, title: str | None = None) -> list[str]:
"""The section anchor ids of text, in heading order.
render(anchors_from=...) feeds these to _heading_ids via
env["anchor_ids"], pinning a translated render's anchors to the
original language's slugs. The selection mirrors _heading_ids exactly
(the same md instance assigns the ids during this parse, author-set
{#id} included as-is); the in-body title h1 is excluded.
"""
if title and not has_h1(text):
text = f"# {title}\n\n{text}"
tokens = md.parse(text, {"page_path": ""})
first_h1 = next(
(
i
for i, t in enumerate(tokens)
if t.type == "heading_open" and t.tag == "h1" and t.level == 0
),
None,
)
return [
t.attrGet("id")
for i, t in enumerate(tokens)
if t.type == "heading_open"
and t.tag in ("h1", "h2")
and t.level == 0
and i != first_h1
]
#: A lone {...} paragraph: a block directive like {dates} or
#: {cards: docs/* news} — name, then optional ":"-separated argument text.
_DIRECTIVE_RE = re.compile(r"\{([a-z][a-z0-9_-]*)(?::([^{}\n]*))?\}")
def _directives(state) -> None:
"""Turn lone ``{name}`` / ``{name: args}`` paragraphs into directive tokens.
The expansion is not markdown.py's business: _directive_rule delegates
to the resolvers render() put in env["directives"], falling back to the
literal source when the tag is unknown in the context (e.g. the editor
preview of a page that does not exist yet). The ``cards`` directive gets .wide so it
stands alone as a full-width block outside the column segments (the
card markup never flows in columns). Runs on the render instance only —
the verbatim parser keeps the plain paragraph so segments/chunks see
the placeholder source.
"""
tokens = state.tokens
out = []
i = 0
while i < len(tokens):
if (
i + 2 < len(tokens)
and tokens[i].type == "paragraph_open"
and tokens[i + 1].type == "inline"
and tokens[i + 2].type == "paragraph_close"
):
inline = tokens[i + 1]
children = inline.children or []
if len(children) == 1 and children[0].type == "text":
m = _DIRECTIVE_RE.fullmatch(children[0].content.strip())
if m:
token = Token("directive", "", 0)
token.level = tokens[i].level
token.map = tokens[i].map
token.content = m.group(0)
token.meta = {
"name": m.group(1),
"args": (m.group(2) or "").strip(),
}
if m.group(1) == "cards":
token.attrSet("class", "wide")
out.append(token)
i += 3
continue
out.append(tokens[i])
i += 1
state.tokens = out
def _directive_rule(self: RendererHTML, tokens, idx: int, options, env: dict) -> str:
"""Render a directive token via env["directives"][name](args, env);
unresolved tags render as the literal source paragraph."""
token = tokens[idx]
resolver = (env.get("directives") or {}).get(token.meta["name"])
html = resolver(token.meta["args"], env) if resolver else None
if html is None:
return f"{escapeHtml(token.content)}
\n"
return html + "\n"
def make_md(*, verbatim: bool = False) -> MarkdownIt:
"""A fully configured parser. The module-level ``md`` (below) is the
render instance; ``verbatim=True`` builds the segmentation instance for
segments.py, where token text must stay byte-identical to the source so
prose spans can be spliced back by offset: no typographer (quotes and
dashes stay straight), no tasklist label wrapping (the item text stays
a plain text token), and soft line breaks (wrapped prose merges into
one segment instead of splitting at hardbreaks)."""
parser = (
MarkdownIt(
"default",
{
"html": True,
"highlight": _highlight,
"typographer": not verbatim,
"breaks": not verbatim,
},
)
.use(attrs_plugin)
.use(admon_plugin)
.use(container_plugin, "block", validate=_container_validate)
.use(footnote_plugin)
.use(deflist_plugin)
# label wrapping (render) puts the item text inside the checkbox
#