aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-20 18:15:29 +0200
committerDanilo M. <danix@danix.xyz>2026-08-20 18:15:29 +0200
commit4f6d1ecb352ad8bba850ca5611ad4881d113bf52 (patch)
tree3e78b366056f925456a7c43420e8ec45f79cc245 /src
parentc50bea78e036518ce1a2a3eb899bbb5e305affea (diff)
downloadqtmaildir-4f6d1ecb352ad8bba850ca5611ad4881d113bf52.tar.gz
qtmaildir-4f6d1ecb352ad8bba850ca5611ad4881d113bf52.zip
feat(compose): render markdown bodies with cmark-gfm, item 123
The composer's body is markdown and the text/html part is generated from it. cmark-gfm rather than plain cmark for autolink: under CommonMark a bare URL in a mail body is not a link, and in mail it is expected to be clickable. Three extensions are enabled and tables are deliberately not, since they render badly across mail clients whoever generates them. Raw HTML in the input is suppressed with CMARK_OPT_SAFE: the body is the user's own text, but a body that can inject markup into its own generated HTML part is a sharp edge with no upside. The build needs TWO lookups. Only the core library ships a pkg-config file; libcmark-gfm-extensions has none and is located with find_library, the way notmuch already is. All three extensions live in that second library, so finding only the first produces a build that compiles and silently renders plain CommonMark. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015muoUo2GdxmBDSp5vjYcbE
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt4
-rw-r--r--src/markdownrenderer.cpp92
-rw-r--r--src/markdownrenderer.h38
3 files changed, 133 insertions, 1 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index b63ff3e..6108696 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -2,6 +2,7 @@ add_library(qtmaildir_lib STATIC
keymap.cpp
config.cpp
mimeparser.cpp
+ markdownrenderer.cpp
requestinterceptor.cpp
htmlbuilder.cpp
cidschemehandler.cpp
@@ -36,7 +37,8 @@ target_include_directories(qtmaildir_lib
target_link_libraries(qtmaildir_lib
PUBLIC Qt6::Widgets Qt6::Svg Qt6::WebEngineWidgets PkgConfig::GMIME
- ${NOTMUCH_LIBRARY})
+ ${NOTMUCH_LIBRARY} PkgConfig::CMARK_GFM
+ ${CMARK_GFM_EXTENSIONS_LIBRARY})
# resources.qrc belongs to the executable, not to the static library. A qrc
# compiled into a .a registers itself from a global initialiser, and the linker
diff --git a/src/markdownrenderer.cpp b/src/markdownrenderer.cpp
new file mode 100644
index 0000000..a9b8177
--- /dev/null
+++ b/src/markdownrenderer.cpp
@@ -0,0 +1,92 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+// cmark-gfm's headers are C and carry no Qt interaction, so the gmime
+// include-order rule does not apply here. They still go first, for consistency
+// with mimeparser.cpp.
+#include <cmark-gfm.h>
+#include <cmark-gfm-core-extensions.h>
+
+#include "markdownrenderer.h"
+
+#include <QByteArray>
+
+#include <cstdlib>
+
+namespace {
+
+/// The extensions this application enables, by cmark-gfm's own names.
+///
+/// `table` is absent deliberately, not by oversight: tables render badly
+/// across mail clients regardless of who generates them. `tagfilter` is absent
+/// because CMARK_OPT_SAFE already suppresses raw HTML wholesale, which is the
+/// stronger measure.
+const char *const kExtensions[] = { "autolink", "strikethrough", "tasklist" };
+
+} // namespace
+
+QString MarkdownRenderer::toHtml(const QString &markdown)
+{
+ if (markdown.isEmpty())
+ return {};
+
+ // Idempotent and required before cmark_find_syntax_extension() can resolve
+ // any name. Calling it per render rather than once at startup keeps this
+ // function free of initialisation order concerns; it is a hash lookup
+ // after the first call.
+ cmark_gfm_core_extensions_ensure_registered();
+
+ // SAFE suppresses raw HTML in the INPUT. It does not escape the output,
+ // which is markup by definition.
+ const int options = CMARK_OPT_DEFAULT | CMARK_OPT_SAFE;
+
+ cmark_parser *parser = cmark_parser_new(options);
+ if (!parser)
+ return {};
+
+ for (const char *name : kExtensions) {
+ // A missing extension is a broken installation rather than a
+ // condition to handle: the library was found by CMake. Skipping it
+ // degrades to plain CommonMark rather than crashing.
+ if (cmark_syntax_extension *extension = cmark_find_syntax_extension(name))
+ cmark_parser_attach_syntax_extension(parser, extension);
+ }
+
+ const QByteArray utf8 = markdown.toUtf8();
+ cmark_parser_feed(parser, utf8.constData(), static_cast<size_t>(utf8.size()));
+
+ cmark_node *document = cmark_parser_finish(parser);
+ if (!document) {
+ cmark_parser_free(parser);
+ return {};
+ }
+
+ // The extension list must be passed to the renderer as well as to the
+ // parser. Passing nullptr here parses the tasklist correctly and then
+ // renders it as a plain list item, which looks like the extension never
+ // worked.
+ char *html = cmark_render_html(document, options,
+ cmark_parser_get_syntax_extensions(parser));
+ const QString result = html ? QString::fromUtf8(html) : QString();
+
+ free(html);
+ cmark_node_free(document);
+ cmark_parser_free(parser);
+
+ return result;
+}
diff --git a/src/markdownrenderer.h b/src/markdownrenderer.h
new file mode 100644
index 0000000..80c52bf
--- /dev/null
+++ b/src/markdownrenderer.h
@@ -0,0 +1,38 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QString>
+
+/// Renders the composer's markdown body into the HTML part's fragment.
+///
+/// A namespace of free functions rather than a class: there is no state, and
+/// keeping it painter-free and widget-free is what lets the extension
+/// configuration be tested on its own. `MessageBuilder` calls this; nothing
+/// else does.
+namespace MarkdownRenderer {
+
+/// The markdown source as an HTML fragment: no <html>, <head> or <body>.
+///
+/// Three extensions are enabled (autolink, strikethrough, tasklist) and
+/// tables are deliberately not. Raw HTML in the input is suppressed by
+/// CMARK_OPT_SAFE.
+QString toHtml(const QString &markdown);
+
+} // namespace MarkdownRenderer