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 --- src/markdownrenderer.h | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 src/markdownrenderer.h (limited to 'src/markdownrenderer.h') 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 -- cgit v1.2.3 From d492192b7a9f682dac4a530a5a68ed74f006ddc2 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 20 Aug 2026 18:26:37 +0200 Subject: fix(compose): correct the header's attribution and harden three tests, item 123 The header still credited CMARK_OPT_SAFE after the .cpp comment and the test were corrected, which left the wrong mechanism named in the file MessageBuilder's author will actually read. Three test weaknesses, each measured rather than assumed. The accented-text test survived a SYMMETRIC latin-1 mutation, since the round trip cancels for codepoints under U+0100, so it now carries a character latin-1 cannot represent. The tasklist test asserted on the bare word "checked", which ordinary prose would satisfy, and now asserts the attribute. And the extension registration is wrapped in a function-local static: cmark-gfm's registry has no once-guard, and this project has a worker thread, so the first call racing itself would tear the registry rather than crash cleanly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015muoUo2GdxmBDSp5vjYcbE --- src/markdownrenderer.cpp | 15 ++++++++++----- src/markdownrenderer.h | 4 +++- tests/CMakeLists.txt | 2 +- tests/test_markdownrenderer.cpp | 11 +++++++++-- 4 files changed, 23 insertions(+), 9 deletions(-) (limited to 'src/markdownrenderer.h') diff --git a/src/markdownrenderer.cpp b/src/markdownrenderer.cpp index ccb3309..7158981 100644 --- a/src/markdownrenderer.cpp +++ b/src/markdownrenderer.cpp @@ -45,11 +45,16 @@ 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(); + // Idempotent, and a hash lookup after the first call. The function-local + // static makes the FIRST call thread-safe: cmark-gfm's registry carries no + // once-guard of its own, so two threads racing the first call would tear + // it. Today's only caller is on the UI thread; this costs nothing and + // removes the trap before a worker-thread caller finds it. + static const bool registered = [] { + cmark_gfm_core_extensions_ensure_registered(); + return true; + }(); + Q_UNUSED(registered) // CMARK_OPT_DEFAULT is 0, and CMARK_OPT_SAFE is a NO-OP in cmark-gfm 0.29: // safe mode has been the default since that release, and the flag is kept diff --git a/src/markdownrenderer.h b/src/markdownrenderer.h index 80c52bf..6373fd9 100644 --- a/src/markdownrenderer.h +++ b/src/markdownrenderer.h @@ -32,7 +32,9 @@ namespace MarkdownRenderer { /// /// Three extensions are enabled (autolink, strikethrough, tasklist) and /// tables are deliberately not. Raw HTML in the input is suppressed by -/// CMARK_OPT_SAFE. +/// cmark-gfm's safe mode, which is the DEFAULT in 0.29 and is not the +/// CMARK_OPT_SAFE flag (a no-op); see markdownrenderer.cpp for the +/// measurement. The requirement is that CMARK_OPT_UNSAFE is never set. QString toHtml(const QString &markdown); } // namespace MarkdownRenderer diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9f6e851..58d5659 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -68,10 +68,10 @@ add_qtmaildir_test(searchterm) add_qtmaildir_test(busyindicator) add_qtmaildir_test(tagstrip) add_qtmaildir_test(messagedetailsdialog) +add_qtmaildir_test(markdownrenderer) add_qtmaildir_test(translations) # Asserts on the tracked .ts rather than the generated .qm: an untranslated # string is dropped by lrelease, so it is invisible in the .qm and shows up # 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 index 0c35b6c..697a28f 100644 --- a/tests/test_markdownrenderer.cpp +++ b/tests/test_markdownrenderer.cpp @@ -74,7 +74,10 @@ void TestMarkdownRenderer::tasklistRenders() 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)); + // Not a bare "checked": that is a common English word ordinary prose + // would satisfy on its own. The attribute is what proves [x] differs + // from [ ]. + QVERIFY2(html.contains(QStringLiteral("checked=\"\"")), qPrintable(html)); } void TestMarkdownRenderer::tablesAreNotEnabled() @@ -127,7 +130,11 @@ void TestMarkdownRenderer::accentedTextSurvivesAsUtf8() // This user writes Italian, so accented text is every message rather // than an edge case, and a UTF-8 round trip through a C library is // exactly where it would be lost. - const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC"); + // + // Includes a character outside latin-1, so a symmetric toLatin1/fromLatin1 + // substitution cannot round-trip it and cancel itself out. Measured: with + // accented latin-1 text alone, mutating both sides together passes. + const QString source = QString::fromUtf8("perch\xC3\xA9 \xC3\xA8 cos\xC3\xAC \xE2\x82\xAC"); const QString html = MarkdownRenderer::toHtml(source); QVERIFY2(html.contains(source), qPrintable(html)); } -- cgit v1.2.3