Skip to content
14 changes: 7 additions & 7 deletions .github/workflows/run-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: |
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ maddy uses [semver versioning](https://semver.org/).

## Upcoming

* ![**FIXED**](https://img.shields.io/badge/-FIXED-%23090) Only create strong and emphasis tags at word boundaries, i.e. `not only_internal_underscores`.
* ![**FIXED**](https://img.shields.io/badge/-FIXED-%23090) Correctly leave any leftover strong or emphasis delimiters outside the tag on either side, however many there are, i.e. `___text__` becomes `_<strong>text</strong>` and `_text_______` becomes `<em>text</em>______`.
* ...

## version 1.6.0 2025-07-26
Expand Down
7 changes: 5 additions & 2 deletions include/maddy/emphasizedparser.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <em> tag instead of into its content.
static std::regex re(
R"((?!.*`.*|.*<code>.*)_(?!.*`.*|.*<\/code>.*)([^_]*)_(?!.*`.*|.*<\/code>.*))"
R"((?!.*`.*|.*<code>.*)\b(_*)_(?![\s_])(?!.*`.*|.*<\/code>.*)(.*?[^\s])_(_*)\b(?!.*`.*|.*<\/code>.*))"
);
static std::string replacement = "<em>$1</em>";
static std::string replacement = "$1<em>$2</em>$3";

line = std::regex_replace(line, re, replacement);
}
Expand Down
26 changes: 14 additions & 12 deletions include/maddy/strongparser.h
Original file line number Diff line number Diff line change
Expand Up @@ -40,19 +40,21 @@ class StrongParser : public LineParser
*/
void Parse(std::string& line) override
{
static std::vector<std::regex> res{
std::regex{
R"((?!.*`.*|.*<code>.*)\*\*(?!.*`.*|.*<\/code>.*)([^\*\*]*)\*\*(?!.*`.*|.*<\/code>.*))"
},
std::regex{
R"((?!.*`.*|.*<code>.*)__(?!.*`.*|.*<\/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"((?!.*`.*|.*<code>.*)\*\*(?![\s])(?!.*`.*|.*<\/code>.*)(.*?[^\s])\*\*(?!.*`.*|.*<\/code>.*))"
};
static std::string replacement = "<strong>$1</strong>";
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 <strong> tag by the caller
// instead of being swallowed into its content.
static std::regex reUnderscore{
R"((?!.*`.*|.*<code>.*)\b(_*)__(?![\s_])(?!.*`.*|.*<\/code>.*)(.*?[^\s])__(_*)\b(?!.*`.*|.*<\/code>.*))"
};
line = std::regex_replace(line, reAsterisk, "<strong>$1</strong>");
line = std::regex_replace(line, reUnderscore, "$1<strong>$2</strong>$3");
}
}; // class StrongParser

Expand Down
145 changes: 145 additions & 0 deletions tests/maddy/test_maddy_emphasizedparser.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,121 @@ TEST(MADDY_EMPHASIZEDPARSER, ItReplacesMarkdownWithEmphasizedHTML)
ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItReplacesUnderscoresAtStringEdges)
{
std::string text = "_some text_";
std::string expected = "<em>some text</em>";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

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 <em>it</em> out";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

emphasizedParser->Parse(text);

ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItOnlyReplacesUnderscoresAtWordBreaks)
{
std::string text = "some _text_bla_ testing _it_ out";
std::string expected = "some <em>text_bla</em> testing <em>it</em> out";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

emphasizedParser->Parse(text);

ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItReplacesUnderscoresWithMultipleWords)
{
std::string text = "some _text testing it_ out";
std::string expected = "some <em>text testing it</em> out";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

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 _<em>text testing it</em> out";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

emphasizedParser->Parse(text);

ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItAllowsTrailingDoubleUnderscores)
{
std::string text = "some _text testing it__ out";
std::string expected = "some <em>text testing it</em>_ out";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

emphasizedParser->Parse(text);

ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItAllowsManyLeadingLeftoverUnderscores)
{
std::string text = "some ____text testing it_ out";
std::string expected = "some ___<em>text testing it</em> out";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

emphasizedParser->Parse(text);

ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItAllowsManyTrailingLeftoverUnderscores)
{
std::string text = "some _text testing it____ out";
std::string expected = "some <em>text testing it</em>___ out";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

emphasizedParser->Parse(text);

ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItDoesntReplaceUnderscoresInsideCodeBlocks)
{
std::string text =
"Stuff inside <code> blocks _shouldn't be emphasized_ </code> at all";
std::string expected =
"Stuff inside <code> blocks _shouldn't be emphasized_ </code> at all";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

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<maddy::EmphasizedParser>();

emphasizedParser->Parse(text);

ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItDoesNotParseInsideInlineCode)
{
std::string text = "some text `*bla*` `/**text*/` testing _it_ out";
Expand All @@ -32,3 +147,33 @@ TEST(MADDY_EMPHASIZEDPARSER, ItDoesNotParseInsideInlineCode)

ASSERT_EQ(expected, text);
}

TEST(MADDY_EMPHASIZEDPARSER, ItParsesOutsideCodeBlocks)
{
std::string text =
"Stuff inside <code> blocks _shouldn't be emphasized_ </code>"
" but outside _should_.";
std::string expected =
"Stuff inside <code> blocks _shouldn't be emphasized_ </code>"
" but outside <em>should</em>.";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

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 <em>should</em>.";
auto emphasizedParser = std::make_shared<maddy::EmphasizedParser>();

emphasizedParser->Parse(text);

ASSERT_EQ(expected, text);
}
Loading