Skip to content

Commit 1fa6ade

Browse files
committed
feat: add md_table helper for Markdown tables in templates
1 parent af1bb75 commit 1fa6ade

4 files changed

Lines changed: 88 additions & 0 deletions

File tree

‎CHANGES.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ v9.9.9 (unreleased)
99
- chore: bump actions/setup-python version (#1773, #1775)
1010
- refactor: switch to pyproject.toml (#1753)
1111
- chore: bump dependencies (#1774, #1777)
12+
- feat: add md_table helper to render Markdown tables in templates (#PRNUM)
1213

1314

1415
v6.2.1 (2026-06-06)

‎docs/user_guide/plugin_development/messaging.rst‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ using `self.send_templated()`:
131131
response = tenv().get_template('Hello/hello.md').render(name=args)
132132
self.send(msg.frm, response)
133133
134+
Building tables by hand in a Jinja2 template is fiddly, so a ``md_table``
135+
helper is available in every template. Give it the rows (an iterable of
136+
iterables) and, optionally, a list of headers, and it returns a Markdown table.
137+
Pipe and newline characters in the cells are escaped for you so the table stays
138+
valid:
139+
140+
.. code-block:: jinja
141+
142+
{{ md_table(rows, headers=['Name', 'Score']) }}
134143
135144
Cards
136145
-----

‎errbot/templating.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from pathlib import Path
33

44
from jinja2 import ChoiceLoader, Environment, FileSystemLoader, PrefixLoader
5+
from markupsafe import Markup
56

67
from errbot.plugin_info import PluginInfo
78

@@ -12,6 +13,39 @@ def make_templates_path(root: Path) -> Path:
1213
return root / "templates"
1314

1415

16+
def _md_table_cell(value) -> str:
17+
text = str(value)
18+
# Newlines and pipes would break a Markdown table, so neutralize them.
19+
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
20+
return text.replace("|", "\\|")
21+
22+
23+
def md_table(rows, headers=None) -> Markup:
24+
"""Render an iterable of rows as a GitHub flavored Markdown table.
25+
26+
``rows`` is an iterable of iterables, one per table row. ``headers`` is an
27+
optional list used for the header row. Cells are turned into strings and any
28+
pipe or newline characters in them are escaped so the table stays valid.
29+
30+
The result is a :class:`~markupsafe.Markup` string so it can be dropped
31+
straight into a Markdown template with ``{{ md_table(rows, headers=[...]) }}``.
32+
"""
33+
rows = [list(row) for row in rows]
34+
header_cells = list(headers) if headers is not None else []
35+
ncols = max([len(header_cells)] + [len(row) for row in rows])
36+
if ncols == 0:
37+
return Markup("")
38+
39+
def line(cells):
40+
cells = [_md_table_cell(c) for c in cells]
41+
cells += [""] * (ncols - len(cells))
42+
return "| " + " | ".join(cells) + " |"
43+
44+
lines = [line(header_cells), "| " + " | ".join(["---"] * ncols) + " |"]
45+
lines += [line(row) for row in rows]
46+
return Markup("\n".join(lines))
47+
48+
1549
system_templates_path = str(make_templates_path(Path(__file__).parent))
1650
template_path = [system_templates_path]
1751
plugin_templates = {} # plugin_name -> FileSystemLoader
@@ -30,6 +64,7 @@ def _recreate_env():
3064
keep_trailing_newline=False,
3165
autoescape=True,
3266
)
67+
env.globals["md_table"] = md_table
3368

3469

3570
_recreate_env()

‎tests/templating_test.py‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from markupsafe import Markup
2+
3+
from errbot.templating import md_table, tenv
4+
5+
6+
def test_md_table_with_headers():
7+
result = md_table([[1, 2], [3, 4]], headers=["a", "b"])
8+
assert result == "| a | b |\n| --- | --- |\n| 1 | 2 |\n| 3 | 4 |"
9+
10+
11+
def test_md_table_without_headers_uses_blank_header():
12+
lines = str(md_table([["x", "y"]])).splitlines()
13+
assert lines[0] == "| | |"
14+
assert lines[1] == "| --- | --- |"
15+
assert lines[2] == "| x | y |"
16+
17+
18+
def test_md_table_pads_short_rows():
19+
lines = str(md_table([["only"]], headers=["a", "b"])).splitlines()
20+
assert lines[2] == "| only | |"
21+
22+
23+
def test_md_table_escapes_pipes_and_newlines():
24+
result = md_table([["a|b", "c\nd"]], headers=["h1", "h2"])
25+
assert "| a\\|b | c d |" in result
26+
27+
28+
def test_md_table_empty():
29+
assert md_table([]) == ""
30+
31+
32+
def test_md_table_returns_markup():
33+
assert isinstance(md_table([["x"]]), Markup)
34+
35+
36+
def test_md_table_available_in_template_env():
37+
assert "md_table" in tenv().globals
38+
rendered = (
39+
tenv()
40+
.from_string("{{ md_table(rows, headers=cols) }}")
41+
.render(rows=[["a", "b"]], cols=["c1", "c2"])
42+
)
43+
assert rendered == "| c1 | c2 |\n| --- | --- |\n| a | b |"

0 commit comments

Comments
 (0)