From 4f6d1ecb352ad8bba850ca5611ad4881d113bf52 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 20 Aug 2026 18:15:29 +0200 Subject: 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 Claude-Session: https://claude.ai/code/session_015muoUo2GdxmBDSp5vjYcbE --- CMakeLists.txt | 17 ++++++ src/CMakeLists.txt | 4 +- src/markdownrenderer.cpp | 92 ++++++++++++++++++++++++++++++ src/markdownrenderer.h | 38 +++++++++++++ tests/CMakeLists.txt | 1 + tests/test_markdownrenderer.cpp | 121 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 src/markdownrenderer.cpp create mode 100644 src/markdownrenderer.h create mode 100644 tests/test_markdownrenderer.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 97a1d90..47b0df6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -34,6 +34,23 @@ message(STATUS "Found notmuch: ${NOTMUCH_LIBRARY}") find_package(PkgConfig REQUIRED) pkg_check_modules(GMIME REQUIRED IMPORTED_TARGET gmime-3.0) +# cmark-gfm renders the composer's markdown body into the HTML part. +# +# TWO lookups, not one, and this is the trap: only the CORE library ships a +# pkg-config file. `libcmark-gfm-extensions` has none (verified 2026-08-20 on +# Slackware, cmark-gfm-0.29.0.gfm.13), so it is located by hand exactly as +# notmuch is. The extensions library is not optional here: autolink, +# strikethrough and tasklist all live in it, and without it a bare URL in a +# mail body is not a link. +pkg_check_modules(CMARK_GFM REQUIRED IMPORTED_TARGET libcmark-gfm) +find_library(CMARK_GFM_EXTENSIONS_LIBRARY NAMES cmark-gfm-extensions) +if(NOT CMARK_GFM_EXTENSIONS_LIBRARY) + message(FATAL_ERROR + "libcmark-gfm-extensions not found. It ships with cmark-gfm but has " + "no pkg-config file; it provides autolink, strikethrough and tasklist.") +endif() +message(STATUS "Found cmark-gfm extensions: ${CMARK_GFM_EXTENSIONS_LIBRARY}") + # The version lives only in the project() call above; version.h is generated # from it so no source file repeats the literal. configure_file( 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. + * + * 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 +#include + +#include "markdownrenderer.h" + +#include + +#include + +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(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. + * + * 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 + +/// 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 , or . +/// +/// 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d1d8a29..9f6e851 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -74,3 +74,4 @@ add_qtmaildir_test(translations) # only as English in a running Italian UI. target_compile_definitions(test_translations PRIVATE TRANSLATIONS_DIR="${CMAKE_SOURCE_DIR}/translations") +add_qtmaildir_test(markdownrenderer) diff --git a/tests/test_markdownrenderer.cpp b/tests/test_markdownrenderer.cpp new file mode 100644 index 0000000..44f3ccb --- /dev/null +++ b/tests/test_markdownrenderer.cpp @@ -0,0 +1,121 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * 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. + */ + +#include + +#include "markdownrenderer.h" + +/// The extension configuration cmark-gfm renders the composer's body with. +/// +/// No QApplication is needed here: MarkdownRenderer is a pure function over +/// strings, so QTEST_APPLESS_MAIN avoids pulling in a platform plugin for a +/// test that has nothing to do with widgets. +class TestMarkdownRenderer : public QObject +{ + Q_OBJECT +private slots: + void commonMarkBasicsRender(); + void autolinkTurnsABareUrlIntoALink(); + void strikethroughRenders(); + void tasklistRenders(); + void tablesAreNotEnabled(); + void rawHtmlIsSuppressed(); + void accentedTextSurvivesAsUtf8(); + void emptyInputProducesEmptyOutput(); +}; + +void TestMarkdownRenderer::commonMarkBasicsRender() +{ + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("**bold** *italic* `code`")); + QVERIFY2(html.contains(QStringLiteral("")), qPrintable(html)); + QVERIFY2(html.contains(QStringLiteral("")), qPrintable(html)); + QVERIFY2(html.contains(QStringLiteral("")), qPrintable(html)); +} + +void TestMarkdownRenderer::autolinkTurnsABareUrlIntoALink() +{ + // The whole reason cmark-gfm was chosen over plain cmark. Under + // CommonMark a bare URL is text, and a bare URL in mail is expected to + // be clickable. + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("see https://example.org for details")); + QVERIFY2(html.contains(QStringLiteral("gone")), qPrintable(html)); +} + +void TestMarkdownRenderer::tasklistRenders() +{ + // Known ceiling: many mail clients strip the checkbox, so those + // recipients see the item with no marker. The plain part still carries + // the literal "- [ ]", so nothing is lost, only the HTML rendering. + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("- [ ] todo\n- [x] done")); + QVERIFY2(html.contains(QStringLiteral("type=\"checkbox\"")), qPrintable(html)); + QVERIFY2(html.contains(QStringLiteral("checked")), qPrintable(html)); +} + +void TestMarkdownRenderer::tablesAreNotEnabled() +{ + // Deliberately off: tables render badly across mail clients regardless of + // who generates them. The extension EXISTS in the library, so this + // asserts a decision rather than a limitation, and would silently start + // passing the wrong way if someone attached it "for completeness". + const QString html = MarkdownRenderer::toHtml( + QStringLiteral("| a | b |\n|---|---|\n| 1 | 2 |")); + QVERIFY2(!html.contains(QStringLiteral("alert(1)\n\nafter")); + QVERIFY2(!html.contains(QStringLiteral("\n\nafter")); QVERIFY2(!html.contains(QStringLiteral("