aboutsummaryrefslogtreecommitdiffstats
path: root/tests
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 /tests
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 'tests')
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_markdownrenderer.cpp121
2 files changed, 122 insertions, 0 deletions
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. <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.
+ */
+
+#include <QtTest>
+
+#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("<strong>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("<em>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("<code>")), 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("<a href=\"https://example.org\"")),
+ qPrintable(html));
+}
+
+void TestMarkdownRenderer::strikethroughRenders()
+{
+ const QString html = MarkdownRenderer::toHtml(QStringLiteral("~~gone~~"));
+ QVERIFY2(html.contains(QStringLiteral("<del>gone</del>")), 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("<table")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("| a | b |")), qPrintable(html));
+}
+
+void TestMarkdownRenderer::rawHtmlIsSuppressed()
+{
+ // 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.
+ const QString html = MarkdownRenderer::toHtml(
+ QStringLiteral("<script>alert(1)</script>\n\nafter"));
+ QVERIFY2(!html.contains(QStringLiteral("<script>")), qPrintable(html));
+ QVERIFY2(html.contains(QStringLiteral("after")), qPrintable(html));
+}
+
+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");
+ const QString html = MarkdownRenderer::toHtml(source);
+ QVERIFY2(html.contains(source), qPrintable(html));
+}
+
+void TestMarkdownRenderer::emptyInputProducesEmptyOutput()
+{
+ // reply_no_quote opens a composer with an empty body and it must not
+ // produce a stray paragraph or crash the renderer.
+ const QString html = MarkdownRenderer::toHtml(QString());
+ QVERIFY2(html.trimmed().isEmpty(), qPrintable(html));
+}
+
+QTEST_APPLESS_MAIN(TestMarkdownRenderer)
+#include "test_markdownrenderer.moc"