fix: stop tripling thread titles in exports; convert non-markdown node data

Three fixes to thread/namespace export:

1. Nodes authored in non-markdown source_format (rst, mediawiki, latex, ...)
   had their raw source dumped directly into the markdown renderer. A RST
   thread exported as .md therefore yielded RST, not markdown. New
   _node_data_as_markdown helper converts node.data through pandoc when
   source_format != markdown, with a raw-source fallback on pandoc failure.

2. node_tree_to_markdown prepended '# {root_node.title}' on top of data
   that already carries its own H1 (either native markdown or a converted
   RST underline heading). Drop the prepend — the data owns the title.

3. convert() passed --metadata title=X which makes pandoc render a visible
   title-block in HTML/PDF above the body. Combined with (2) and the data's
   own H1 this showed the title three times in exported HTML/PDF. For
   html-family outputs (and pdf via wkhtmltopdf) switch to -V pagetitle=X
   so only the <title> tag gets populated; other formats still use the
   proper --metadata title=X for real document metadata.
This commit is contained in:
russell@unturf.com 2026-04-24 14:38:26 -04:00
parent 2fd71e9029
commit 8b6dac4dd7
3 changed files with 48 additions and 12 deletions

View file

@ -206,7 +206,15 @@ def convert(source, from_format="markdown", to_format="html5", title=None, stand
cmd.append("--standalone")
if title:
cmd.extend(["--metadata", "title={}".format(title)])
# For HTML-family outputs (and PDF, which we render through HTML via
# wkhtmltopdf), set the template's pagetitle so we get <title> without
# an extra title-block in the body — the rendered document already
# carries its own H1 from the thread data. For every other format,
# set the proper document metadata.
if to_format in ("html", "html5", "html4", "chunkedhtml", "pdf"):
cmd.extend(["-V", "pagetitle={}".format(title)])
else:
cmd.extend(["--metadata", "title={}".format(title)])
# PDF needs explicit engine since no pdflatex.
if to_format == "pdf":
@ -240,6 +248,33 @@ def convert(source, from_format="markdown", to_format="html5", title=None, stand
return result.stdout
def _node_data_as_markdown(node):
"""Return node.data rendered as markdown.
Nodes authored in non-markdown source_formats (rst, mediawiki, latex, html,
...) keep their raw source in .data. For export we need real markdown, so
convert through pandoc when needed. A pandoc failure falls back to the raw
source better a rough dump than an empty export.
"""
if not node.data:
return ""
src_fmt = (getattr(node, "source_format", None) or "markdown").lower()
if src_fmt in ("markdown", "gfm", "commonmark", "commonmark_x"):
return node.data
try:
return convert(
node.data, from_format=src_fmt, to_format="markdown",
standalone=False,
).rstrip()
except Exception:
log.exception(
"Pandoc failed converting node %s from %s to markdown; "
"falling back to raw source",
node.id, src_fmt,
)
return node.data
def node_tree_to_markdown(root_node, nodes, include_root=True):
"""Render a node tree as a nested markdown document.
@ -271,12 +306,11 @@ def node_tree_to_markdown(root_node, nodes, include_root=True):
lines = []
if include_root and root_node.title:
lines.append("# {}".format(root_node.title))
lines.append("")
# Thread bodies already carry their own H1 (or RST ==== underline that
# becomes H1 after conversion). Don't prepend another `# {title}` — it
# triples the title when pandoc then also adds a title-block in HTML/PDF.
if include_root and root_node.data:
lines.append(root_node.data)
lines.append(_node_data_as_markdown(root_node))
lines.append("")
def _render_children(parent_id, depth):
@ -290,7 +324,7 @@ def node_tree_to_markdown(root_node, nodes, include_root=True):
lines.append("{} {}{}".format("#" * heading_level, author, date))
lines.append("")
if child.data:
lines.append(child.data)
lines.append(_node_data_as_markdown(child))
lines.append("")
_render_children(child.id, depth + 1)
@ -325,7 +359,7 @@ def namespace_to_markdown(namespace, roots, node_fetcher):
lines.append("## {}".format(chapter_title))
lines.append("")
if root.data:
lines.append(root.data)
lines.append(_node_data_as_markdown(root))
lines.append("")
# Fetch all nodes in this thread
@ -354,7 +388,7 @@ def namespace_to_markdown(namespace, roots, node_fetcher):
))
lines.append("")
if child.data:
lines.append(child.data)
lines.append(_node_data_as_markdown(child))
lines.append("")
_render(child.id, depth + 1)

View file

@ -173,7 +173,9 @@ class TestNodeTreeToMarkdown(unittest.TestCase):
def test_single_root_node(self):
root = MockNode(1, title="Thread Title", data="Root content.")
md = node_tree_to_markdown(root, [])
self.assertIn("# Thread Title", md)
# We no longer prepend `# {title}`; the thread data carries its own
# heading. Duplicating it produced three title copies in HTML/PDF.
self.assertNotIn("# Thread Title", md)
self.assertIn("Root content.", md)
def test_root_with_children(self):
@ -181,7 +183,6 @@ class TestNodeTreeToMarkdown(unittest.TestCase):
child = MockNode(2, data="Reply text.", parent_id=1, created=1,
user=MockUser("alice"))
md = node_tree_to_markdown(root, [child])
self.assertIn("# Thread", md)
self.assertIn("Root.", md)
self.assertIn("alice", md)
self.assertIn("Reply text.", md)

View file

@ -123,7 +123,8 @@ class TestExportThread(UndiggFunctionalTests):
)
self.assertEqual(res.status_int, 200)
self.assertIn("text/markdown", res.content_type)
self.assertIn(b"Test Thread", res.body)
# Markdown export is a short-circuit — no pandoc, no title block;
# we render the thread data exactly so it carries its own heading.
self.assertIn(b"Thread content.", res.body)
def test_export_thread_html(self):