diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml
index 29cf5d6..144e73c 100644
--- a/.github/workflows/run-tests.yml
+++ b/.github/workflows/run-tests.yml
@@ -12,8 +12,8 @@ jobs:
- uses: actions/checkout@v6
- uses: lukka/get-cmake@latest
with:
- cmakeVersion: "~3.25.0"
- ninjaVersion: "^1.11.1"
+ cmakeVersion: "~4.2.0" # <--= optional, use most recent 4.2.x version
+ ninjaVersion: "^1.11.1" # <--= optional, use most recent 1.x version
- name: build
run: |
mkdir tmp
@@ -39,13 +39,13 @@ jobs:
- uses: actions/checkout@v6
- uses: lukka/get-cmake@latest
with:
- cmakeVersion: "~3.25.0"
- ninjaVersion: "^1.11.1"
+ cmakeVersion: "~4.2.0" # <--= optional, use most recent 4.2.x version
+ ninjaVersion: "^1.11.1" # <--= optional, use most recent 1.x version
- name: build
run: |
mkdir tmp
cd tmp
- cmake -G "Visual Studio 17 2022" -A x64 -DMADDY_BUILD_WITH_TESTS=ON ..
+ cmake -G "Visual Studio 18 2026" -A x64 -DMADDY_BUILD_WITH_TESTS=ON ..
cmake --build . --config Debug
- name: run tests
run: |
@@ -57,8 +57,8 @@ jobs:
- uses: actions/checkout@v6
- uses: lukka/get-cmake@latest
with:
- cmakeVersion: "~3.25.0"
- ninjaVersion: "^1.11.1"
+ cmakeVersion: "~4.2.0" # <--= optional, use most recent 4.2.x version
+ ninjaVersion: "^1.11.1" # <--= optional, use most recent 1.x version
- name: build
run: |
mkdir tmp
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a16cc40..36ce8f3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -14,6 +14,8 @@ maddy uses [semver versioning](https://semver.org/).
## Upcoming
+*  Only create strong and emphasis tags at word boundaries, i.e. `not only_internal_underscores`.
+*  Correctly leave any leftover strong or emphasis delimiters outside the tag on either side, however many there are, i.e. `___text__` becomes `_text` and `_text_______` becomes `text______`.
* ...
## version 1.6.0 2025-07-26
diff --git a/include/maddy/emphasizedparser.h b/include/maddy/emphasizedparser.h
index 1705e6f..377e674 100644
--- a/include/maddy/emphasizedparser.h
+++ b/include/maddy/emphasizedparser.h
@@ -40,10 +40,13 @@ class EmphasizedParser : public LineParser
*/
void Parse(std::string& line) override
{
+ // The leading and trailing `(_*)` groups absorb any leftover underscores
+ // from an unbalanced run (e.g. `__foo_` or `_foo____`), re-emitted
+ // outside the tag instead of into its content.
static std::regex re(
- R"((?!.*`.*|.*.*)_(?!.*`.*|.*<\/code>.*)([^_]*)_(?!.*`.*|.*<\/code>.*))"
+ R"((?!.*`.*|.*.*)\b(_*)_(?![\s_])(?!.*`.*|.*<\/code>.*)(.*?[^\s])_(_*)\b(?!.*`.*|.*<\/code>.*))"
);
- static std::string replacement = "$1";
+ static std::string replacement = "$1$2$3";
line = std::regex_replace(line, re, replacement);
}
diff --git a/include/maddy/strongparser.h b/include/maddy/strongparser.h
index 348f2d4..b6c0de7 100644
--- a/include/maddy/strongparser.h
+++ b/include/maddy/strongparser.h
@@ -40,19 +40,21 @@ class StrongParser : public LineParser
*/
void Parse(std::string& line) override
{
- static std::vector res{
- std::regex{
- R"((?!.*`.*|.*.*)\*\*(?!.*`.*|.*<\/code>.*)([^\*\*]*)\*\*(?!.*`.*|.*<\/code>.*))"
- },
- std::regex{
- R"((?!.*`.*|.*.*)__(?!.*`.*|.*<\/code>.*)([^__]*)__(?!.*`.*|.*<\/code>.*))"
- }
+ // `*` is not a word character, so `\b` next to it does not mean
+ // "edge of a delimiter run" the way it does for `_`; the asterisk
+ // variant is left without a word-boundary anchor.
+ static std::regex reAsterisk{
+ R"((?!.*`.*|.*.*)\*\*(?![\s])(?!.*`.*|.*<\/code>.*)(.*?[^\s])\*\*(?!.*`.*|.*<\/code>.*))"
};
- static std::string replacement = "$1";
- for (const auto& re : res)
- {
- line = std::regex_replace(line, re, replacement);
- }
+ // The leading and trailing `(_*)` groups absorb any leftover underscores
+ // from an unbalanced run on either side (e.g. `___text__` or
+ // `__text_______`), re-emitted outside the tag by the caller
+ // instead of being swallowed into its content.
+ static std::regex reUnderscore{
+ R"((?!.*`.*|.*.*)\b(_*)__(?![\s_])(?!.*`.*|.*<\/code>.*)(.*?[^\s])__(_*)\b(?!.*`.*|.*<\/code>.*))"
+ };
+ line = std::regex_replace(line, reAsterisk, "$1");
+ line = std::regex_replace(line, reUnderscore, "$1$2$3");
}
}; // class StrongParser
diff --git a/tests/maddy/test_maddy_emphasizedparser.cpp b/tests/maddy/test_maddy_emphasizedparser.cpp
index 6442779..9e1cd52 100644
--- a/tests/maddy/test_maddy_emphasizedparser.cpp
+++ b/tests/maddy/test_maddy_emphasizedparser.cpp
@@ -21,6 +21,121 @@ TEST(MADDY_EMPHASIZEDPARSER, ItReplacesMarkdownWithEmphasizedHTML)
ASSERT_EQ(expected, text);
}
+TEST(MADDY_EMPHASIZEDPARSER, ItReplacesUnderscoresAtStringEdges)
+{
+ std::string text = "_some text_";
+ std::string expected = "some text";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItDoesNotReplaceMarkdownWithInlineUnderscores)
+{
+ std::string text = "some text_bla_text testing _it_ out";
+ std::string expected = "some text_bla_text testing it out";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItOnlyReplacesUnderscoresAtWordBreaks)
+{
+ std::string text = "some _text_bla_ testing _it_ out";
+ std::string expected = "some text_bla testing it out";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItReplacesUnderscoresWithMultipleWords)
+{
+ std::string text = "some _text testing it_ out";
+ std::string expected = "some text testing it out";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItAllowsDoubleUnderscores)
+{
+ // Per CommonMark, a leftover delimiter from an unbalanced run renders
+ // outside the tag it didn't pair into, not inside it.
+ std::string text = "some __text testing it_ out";
+ std::string expected = "some _text testing it out";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItAllowsTrailingDoubleUnderscores)
+{
+ std::string text = "some _text testing it__ out";
+ std::string expected = "some text testing it_ out";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItAllowsManyLeadingLeftoverUnderscores)
+{
+ std::string text = "some ____text testing it_ out";
+ std::string expected = "some ___text testing it out";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItAllowsManyTrailingLeftoverUnderscores)
+{
+ std::string text = "some _text testing it____ out";
+ std::string expected = "some text testing it___ out";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItDoesntReplaceUnderscoresInsideCodeBlocks)
+{
+ std::string text =
+ "Stuff inside blocks _shouldn't be emphasized_ at all";
+ std::string expected =
+ "Stuff inside blocks _shouldn't be emphasized_ at all";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItDoesNotReplaceUnderscoresInURLs)
+{
+ std::string text = "[Link Title](http://example.com/what_you_didn't_know)";
+ std::string expected =
+ "[Link Title](http://example.com/what_you_didn't_know)";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
TEST(MADDY_EMPHASIZEDPARSER, ItDoesNotParseInsideInlineCode)
{
std::string text = "some text `*bla*` `/**text*/` testing _it_ out";
@@ -32,3 +147,33 @@ TEST(MADDY_EMPHASIZEDPARSER, ItDoesNotParseInsideInlineCode)
ASSERT_EQ(expected, text);
}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItParsesOutsideCodeBlocks)
+{
+ std::string text =
+ "Stuff inside blocks _shouldn't be emphasized_ "
+ " but outside _should_.";
+ std::string expected =
+ "Stuff inside blocks _shouldn't be emphasized_ "
+ " but outside should.";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_EMPHASIZEDPARSER, ItParsesOutsideTickBlocks)
+{
+ std::string text =
+ "Stuff inside `blocks _shouldn't be emphasized_ `"
+ " but outside _should_.";
+ std::string expected =
+ "Stuff inside `blocks _shouldn't be emphasized_ `"
+ " but outside should.";
+ auto emphasizedParser = std::make_shared();
+
+ emphasizedParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
diff --git a/tests/maddy/test_maddy_strongparser.cpp b/tests/maddy/test_maddy_strongparser.cpp
index f006e26..1211b0e 100644
--- a/tests/maddy/test_maddy_strongparser.cpp
+++ b/tests/maddy/test_maddy_strongparser.cpp
@@ -83,3 +83,149 @@ TEST(MADDY_STRONGPARSER, ItDoesNotParseInsideInlineCode)
ASSERT_EQ(test.expected, test.text);
}
}
+
+TEST(MADDY_STRONGPARSER, ItReplacesUnderscoresAtStringEdges)
+{
+ std::string text = "__some text__";
+ std::string expected = "some text";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItDoesNotReplaceMarkdownWithInlineUnderscores)
+{
+ std::string text = "some text__bla__text testing __it__ out";
+ std::string expected = "some text__bla__text testing it out";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItOnlyReplacesUnderscoresAtWordBreaks)
+{
+ std::string text = "some __text__bla__ testing __it__ out";
+ std::string expected =
+ "some text__bla testing it out";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItReplacesUnderscoresWithMultipleWords)
+{
+ std::string text = "some __text testing it__ out";
+ std::string expected = "some text testing it out";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItAllowsTripleUnderscores)
+{
+ // Per CommonMark, a leftover delimiter from an unbalanced run renders
+ // outside the tag it didn't pair into, not inside it.
+ std::string text = "some ___text testing it__ out";
+ std::string expected = "some _text testing it out";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItAllowsTrailingTripleUnderscores)
+{
+ std::string text = "some __text testing it___ out";
+ std::string expected = "some text testing it_ out";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItAllowsManyLeadingLeftoverUnderscores)
+{
+ std::string text = "some ________text testing it__ out";
+ std::string expected = "some ______text testing it out";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItAllowsManyTrailingLeftoverUnderscores)
+{
+ std::string text = "some __text testing it_______ out";
+ std::string expected = "some text testing it_____ out";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItDoesntReplaceUnderscoresInsideCodeBlocks)
+{
+ std::string text =
+ "Stuff inside blocks __shouldn't be strong__ at all";
+ std::string expected =
+ "Stuff inside blocks __shouldn't be strong__ at all";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItDoesNotReplaceUnderscoresInURLs)
+{
+ std::string text = "[Link Title](http://example.com/what__you__didn't__know)";
+ std::string expected =
+ "[Link Title](http://example.com/what__you__didn't__know)";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItParsesOutsideCodeBlocks)
+{
+ std::string text =
+ "Stuff inside blocks __shouldn't be strong__ "
+ " but outside __should__.";
+ std::string expected =
+ "Stuff inside blocks __shouldn't be strong__ "
+ " but outside should.";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}
+
+TEST(MADDY_STRONGPARSER, ItParsesOutsideTickBlocks)
+{
+ std::string text =
+ "Stuff inside `blocks __shouldn't be strong__ `"
+ " but outside __should__.";
+ std::string expected =
+ "Stuff inside `blocks __shouldn't be strong__ `"
+ " but outside should.";
+ auto strongParser = std::make_shared();
+
+ strongParser->Parse(text);
+
+ ASSERT_EQ(expected, text);
+}