Changes
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 964f737..c3af10c 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -47,7 +47,7 @@ target_link_libraries(macrodown PRIVATE macrodown_lib)
# Testing
enable_testing()
-add_executable(macrodown_test tests/test_main.cpp tests/test_macro_engine.cpp tests/test_block_parser.cpp tests/test_nodes.cpp tests/test_macrodown.cpp)
+add_executable(macrodown_test tests/test_main.cpp tests/test_macro_engine.cpp tests/test_block_parser.cpp tests/test_nodes.cpp tests/test_macrodown.cpp tests/test_custom_markup.cpp)
target_link_libraries(macrodown_test PRIVATE macrodown_lib GTest::gtest_main)
target_include_directories(macrodown_test PRIVATE include)
diff --git a/design.md b/design.md
index 0af56e2..c91da2c 100644
--- a/design.md
+++ b/design.md
@@ -33,6 +33,13 @@ We strictly follow the CommonMark "Appendix A" strategy:
* `# Heading` $\rightarrow$ `%h1{Heading}`
* `*Bold*` $\rightarrow$ `%em{Bold}`
+### 2.3 Custom Markups
+The system supports user-defined custom markups that map to macros.
+* **Prefix Markup**: Starts with a specific character (e.g., `#tag`) and ends at a whitespace or punctuation boundary (except `_`).
+ * Example: `#tag` $\rightarrow$ `%tag_macro{tag}`
+* **Delimited Markup**: Starts and ends with the same character (e.g., `:highlight:`) with no spaces inside and strict punctuation rules.
+ * Example: `:highlight:` $\rightarrow$ `%highlight_macro{highlight}`
+
## 3. Data Structures
### 3.1 AST Nodes
@@ -109,6 +116,7 @@ The `MacroDown` class provides a simplified two-step interface for rendering doc
* **Step 1: Parse** (`parse`): Takes a source string and returns a single root `Node` (the syntax tree).
* **Step 2: Render** (`render`): Takes the root `Node` and produces the final HTML string using the internal `Evaluator`.
+* **Configuration**: Allows defining custom markups via `definePrefixMarkup` and `defineDelimitedMarkup`.
It automatically initializes the standard library of macros.
@@ -126,6 +134,7 @@ It automatically initializes the standard library of macros.
* **Mechanism**:
* Scans for delimiters (`*`, `_`, `[`, `` ` ``, `!`).
* **Crucially**: Scans for the Macro start character `%`.
+ * **Custom Markups**: Scans for user-defined prefix and delimited markups.
* Uses the "Delimiter Stack" algorithm from CommonMark spec to resolve emphasis nesting.
* **Output**: Converts the block's text into a list of `Node`s (Text and Macro nodes).
diff --git a/include/converter.h b/include/converter.h
index f5adb52..b0282c2 100644
--- a/include/converter.h
+++ b/include/converter.h
@@ -5,6 +5,7 @@
#include <memory>
#include "block.h"
#include "nodes.h"
+#include "markups.h"
namespace macrodown
{
@@ -13,10 +14,16 @@ class Converter
{
public:
// Converts a Block tree into a list of AST Nodes (Macros)
- static std::vector<std::unique_ptr<Node>> convert(const Block* root);
+ static std::vector<std::unique_ptr<Node>> convert(
+ const Block* root,
+ const std::vector<PrefixMarkup>& prefix_markups = {},
+ const std::vector<DelimitedMarkup>& delimited_markups = {});
private:
- static std::unique_ptr<Node> convert_block(const Block* block);
+ static std::unique_ptr<Node> convert_block(
+ const Block* block,
+ const std::vector<PrefixMarkup>& prefix_markups,
+ const std::vector<DelimitedMarkup>& delimited_markups);
};
} // namespace macrodown
diff --git a/include/macrodown.h b/include/macrodown.h
index 7335323..4899328 100644
--- a/include/macrodown.h
+++ b/include/macrodown.h
@@ -3,8 +3,10 @@
#include <string>
#include <memory>
+#include <vector>
#include "nodes.h"
#include "macro_engine.h"
+#include "markups.h"
namespace macrodown
{
@@ -33,8 +35,20 @@ public:
*/
Evaluator& evaluator() { return evaluator_; }
+ /**
+ * Define a prefix markup.
+ */
+ void definePrefixMarkup(const PrefixMarkup& markup);
+
+ /**
+ * Define a delimited markup.
+ */
+ void defineDelimitedMarkup(const DelimitedMarkup& markup);
+
private:
Evaluator evaluator_;
+ std::vector<PrefixMarkup> prefix_markups_;
+ std::vector<DelimitedMarkup> delimited_markups_;
};
} // namespace macrodown
diff --git a/include/markups.h b/include/markups.h
new file mode 100644
index 0000000..d2e136c
--- /dev/null
+++ b/include/markups.h
@@ -0,0 +1,23 @@
+#ifndef MACRODOWN_MARKUPS_H
+#define MACRODOWN_MARKUPS_H
+
+#include <string>
+
+namespace macrodown
+{
+
+struct PrefixMarkup
+{
+ std::string prefix;
+ std::string macro_name;
+};
+
+struct DelimitedMarkup
+{
+ std::string delimiter;
+ std::string macro_name;
+};
+
+} // namespace macrodown
+
+#endif // MACRODOWN_MARKUPS_H
diff --git a/include/parser.h b/include/parser.h
index 1e89532..91028b0 100644
--- a/include/parser.h
+++ b/include/parser.h
@@ -5,6 +5,7 @@
#include <vector>
#include <memory>
#include "nodes.h"
+#include "markups.h"
namespace macrodown
{
@@ -14,7 +15,10 @@ class Parser
public:
// Parses a string into a list of nodes (Text and Macros)
// This handles the Inline Parsing phase (Phase 2 of the design)
- static std::vector<std::unique_ptr<Node>> parse(const std::string& input);
+ static std::vector<std::unique_ptr<Node>> parse(
+ const std::string& input,
+ const std::vector<PrefixMarkup>& prefix_markups = {},
+ const std::vector<DelimitedMarkup>& delimited_markups = {});
};
} // namespace macrodown
diff --git a/prd.md b/prd.md
index 977d71a..73d17ea 100644
--- a/prd.md
+++ b/prd.md
@@ -59,3 +59,36 @@ The library should expose an interface that allows the user to render
a document with 2 steps: The first step will expose the syntax tree
(the root node), and the second step will render the syntax tree into
HTML.
+
+## Custom markups
+
+An interface for the user to define custom markup should be exposed.
+The user will be able to define two kinds of markup:
+
+1. A markup starts with a prefix. For example, the user can define `#`
+ as a prefix, then in `It’s a #test.`, `#test` is the part that’s
+ being marked up. In other words, for a prefix markup, the text
+ that’s marked up begin with the prefix, and ends with any
+ whitespace or punctuation (excluding the underscroe `_`).
+2. A delimited markup. This kind of custom markup has starts with a
+ character and ends with the same delimiting character. No
+ whitespace or punctuation are allowed in between, except for the
+ underscore `_` and the dash `-`. If whitespace or punctuation is
+ found between the delimiting character, it’s not a markup. For
+ example, suppose the user defines `:` to be a delimiter, then in
+ `it’s a :test:.`, `:test:` is the part that’s being marked up;
+ however in `it’s a :test.:`, nothing is marked up.
+
+In both cases, the special character is a single unicode character,
+which could be multi-byte. The markup is defined with a function, and
+is converted into a macro with one argument, the argument being the
+text being marked up, without the special character. The first kind is
+defined with `definePrefixMarkup(const std::string& prefix, const
+std::string& macro_name)`. The second kind is defined with
+`defineDelimitedMarkup(const std::string& delimiter, const
+std::string& macro_name)`. In the example with `#` defining a prefix
+markup, the defining call would be
+
+ definePrefixMarkup("#", "tag");
+
+which would transform `#test` into a macro `%tag{test}`.
diff --git a/src/converter.cpp b/src/converter.cpp
index a6f6081..85e4c2b 100644
--- a/src/converter.cpp
+++ b/src/converter.cpp
@@ -1,11 +1,15 @@
#include "converter.h"
#include "parser.h"
+#include "macrodown.h"
#include <iostream>
namespace macrodown
{
-std::vector<std::unique_ptr<Node>> Converter::convert(const Block* root)
+std::vector<std::unique_ptr<Node>> Converter::convert(
+ const Block* root,
+ const std::vector<PrefixMarkup>& prefix_markups,
+ const std::vector<DelimitedMarkup>& delimited_markups)
{
std::vector<std::unique_ptr<Node>> nodes;
@@ -13,7 +17,7 @@ std::vector<std::unique_ptr<Node>> Converter::convert(const Block* root)
{
for(const auto& child : root->children)
{
- auto node = convert_block(child.get());
+ auto node = convert_block(child.get(), prefix_markups, delimited_markups);
if(node)
{
nodes.push_back(std::move(node));
@@ -23,14 +27,17 @@ std::vector<std::unique_ptr<Node>> Converter::convert(const Block* root)
else
{
// Should not happen if root is Document, but handle single block
- auto node = convert_block(root);
+ auto node = convert_block(root, prefix_markups, delimited_markups);
if(node) nodes.push_back(std::move(node));
}
return nodes;
}
-std::unique_ptr<Node> Converter::convert_block(const Block* block)
+std::unique_ptr<Node> Converter::convert_block(
+ const Block* block,
+ const std::vector<PrefixMarkup>& prefix_markups,
+ const std::vector<DelimitedMarkup>& delimited_markups)
{
if(!block) return nullptr;
@@ -59,7 +66,7 @@ std::unique_ptr<Node> Converter::convert_block(const Block* block)
{
// Leaf block: Parse literal content
Group group;
- auto inline_nodes = Parser::parse(block->literal_content);
+ auto inline_nodes = Parser::parse(block->literal_content, prefix_markups, delimited_markups);
for(auto& n : inline_nodes)
{
group.addChild(std::move(n));
@@ -72,7 +79,7 @@ std::unique_ptr<Node> Converter::convert_block(const Block* block)
Group group;
for(const auto& child : block->children)
{
- auto child_node = convert_block(child.get());
+ auto child_node = convert_block(child.get(), prefix_markups, delimited_markups);
if(child_node)
{
group.addChild(std::move(child_node));
diff --git a/src/macrodown.cpp b/src/macrodown.cpp
index cc53173..f96e574 100644
--- a/src/macrodown.cpp
+++ b/src/macrodown.cpp
@@ -14,7 +14,7 @@ MacroDown::MacroDown()
std::unique_ptr<Node> MacroDown::parse(const std::string& input)
{
auto block_root = BlockParser::parse(input);
- auto macro_nodes = Converter::convert(block_root.get());
+ auto macro_nodes = Converter::convert(block_root.get(), prefix_markups_, delimited_markups_);
if(macro_nodes.empty())
{
@@ -40,4 +40,14 @@ std::string MacroDown::render(const Node& root)
return evaluator_.evaluate(root);
}
+void MacroDown::definePrefixMarkup(const PrefixMarkup& markup)
+{
+ prefix_markups_.push_back(markup);
+}
+
+void MacroDown::defineDelimitedMarkup(const DelimitedMarkup& markup)
+{
+ delimited_markups_.push_back(markup);
+}
+
} // namespace macrodown
diff --git a/src/parser.cpp b/src/parser.cpp
index ba0f62a..16e256f 100644
--- a/src/parser.cpp
+++ b/src/parser.cpp
@@ -1,37 +1,140 @@
#include "parser.h"
+#include "macrodown.h"
+#include "uni_algo/all.h"
#include <iostream>
namespace macrodown
{
-std::vector<std::unique_ptr<Node>> Parser::parse(const std::string& input)
+std::vector<std::unique_ptr<Node>> Parser::parse(
+ const std::string& input,
+ const std::vector<PrefixMarkup>& prefix_markups,
+ const std::vector<DelimitedMarkup>& delimited_markups)
{
+ std::u32string input32 = una::utf8to32u(input);
std::vector<std::unique_ptr<Node>> nodes;
- std::string current_text;
+ std::u32string current_text;
auto flush_text = [&]()
{
if(!current_text.empty())
{
- nodes.push_back(std::make_unique<Node>(Text{current_text}));
+ nodes.push_back(std::make_unique<Node>(Text{una::utf32to8(current_text)}));
current_text.clear();
}
};
+ // Pre-calculate code points for custom markups
+ struct PrefixInfo {
+ char32_t cp;
+ std::string macro;
+ };
+ std::vector<PrefixInfo> p_infos;
+ for(const auto& m : prefix_markups)
+ {
+ auto cp = una::utf8to32u(m.prefix);
+ if(!cp.empty()) p_infos.push_back({cp[0], m.macro_name});
+ }
+
+ struct DelimInfo {
+ char32_t cp;
+ std::string macro;
+ };
+ std::vector<DelimInfo> d_infos;
+ for(const auto& m : delimited_markups)
+ {
+ auto cp = una::utf8to32u(m.delimiter);
+ if(!cp.empty()) d_infos.push_back({cp[0], m.macro_name});
+ }
+
size_t i = 0;
- while(i < input.length())
+ while(i < input32.length())
{
- char c = input[i];
+ char32_t c = input32[i];
// Escape handling
- if(c == '\\' && i + 1 < input.length())
+ if(c == '\\' && i + 1 < input32.length())
{
- // Append the escaped character
- current_text += input[i+1];
+ current_text += input32[i+1];
i += 2;
continue;
}
+ // Custom Prefix Markup
+ bool matched_prefix = false;
+ for(const auto& info : p_infos)
+ {
+ if(c == info.cp)
+ {
+ // Scan until whitespace or punctuation (except _)
+ size_t j = i + 1;
+ while(j < input32.length())
+ {
+ char32_t next_c = input32[j];
+ if(una::codepoint::is_whitespace(next_c)) break;
+ // Punctuation check using uni-algo
+ if(next_c != '_' && una::codepoint::prop{next_c}.General_Category_P()) break;
+ j++;
+ }
+
+ if(j > i + 1)
+ {
+ flush_text();
+ std::u32string content = input32.substr(i + 1, j - (i + 1));
+ Macro macro;
+ macro.name = info.macro;
+ Group group;
+ group.addChild(std::make_unique<Node>(Text{una::utf32to8(content)}));
+ macro.arguments.push_back(std::make_unique<Node>(std::move(group)));
+ nodes.push_back(std::make_unique<Node>(std::move(macro)));
+ i = j;
+ matched_prefix = true;
+ break;
+ }
+ }
+ }
+ if(matched_prefix) continue;
+
+ // Custom Delimited Markup
+ bool matched_delim = false;
+ for(const auto& info : d_infos)
+ {
+ if(c == info.cp)
+ {
+ size_t j = i + 1;
+ bool valid = true;
+ bool found_end = false;
+ while(j < input32.length())
+ {
+ char32_t next_c = input32[j];
+ if(next_c == info.cp)
+ {
+ found_end = true;
+ break;
+ }
+ if(una::codepoint::is_whitespace(next_c)) { valid = false; break; }
+ if(next_c != '_' && next_c != '-' && una::codepoint::prop{next_c}.General_Category_P()) { valid = false; break; }
+ j++;
+ }
+
+ if(found_end && valid && j > i + 1)
+ {
+ flush_text();
+ std::u32string content = input32.substr(i + 1, j - (i + 1));
+ Macro macro;
+ macro.name = info.macro;
+ Group group;
+ group.addChild(std::make_unique<Node>(Text{una::utf32to8(content)}));
+ macro.arguments.push_back(std::make_unique<Node>(std::move(group)));
+ nodes.push_back(std::make_unique<Node>(std::move(macro)));
+ i = j + 1;
+ matched_delim = true;
+ break;
+ }
+ }
+ }
+ if(matched_delim) continue;
+
// Macro: %name{args}...
if(c == '%')
{
@@ -39,15 +142,16 @@ std::vector<std::unique_ptr<Node>> Parser::parse(const std::string& input)
i++; // skip %
size_t name_start = i;
- while(i < input.length() && (isalnum(input[i]) || input[i] == '_'))
+ while(i < input32.length() && (una::codepoint::is_alphanumeric(input32[i]) || input32[i] == '_'))
{
i++;
}
- std::string name = input.substr(name_start, i - name_start);
+ std::u32string name32 = input32.substr(name_start, i - name_start);
+ std::string name = una::utf32to8(name32);
if(name.empty())
{
- current_text += "%";
+ current_text += '%';
continue;
}
@@ -55,37 +159,37 @@ std::vector<std::unique_ptr<Node>> Parser::parse(const std::string& input)
macro.name = name;
// Parse Arguments
- while(i < input.length())
+ while(i < input32.length())
{
- char open = input[i];
+ char32_t open = input32[i];
if(open == '{' || open == '[')
{
- char close = (open == '{') ? '}' : ']';
+ char32_t close = (open == '{') ? '}' : ']';
i++;
- std::string arg_content;
+ std::u32string arg_content32;
int balance = 1;
- while(i < input.length() && balance > 0)
+ while(i < input32.length() && balance > 0)
{
- if(input[i] == open)
+ if(input32[i] == open)
{
balance++;
- arg_content += input[i];
+ arg_content32 += input32[i];
}
- else if(input[i] == close)
+ else if(input32[i] == close)
{
balance--;
- if(balance > 0) arg_content += input[i];
+ if(balance > 0) arg_content32 += input32[i];
}
else
{
- arg_content += input[i];
+ arg_content32 += input32[i];
}
i++;
}
Group group;
- std::vector<std::unique_ptr<Node>> sub_nodes = parse(arg_content);
+ std::vector<std::unique_ptr<Node>> sub_nodes = parse(una::utf32to8(arg_content32), prefix_markups, delimited_markups);
for(auto& n : sub_nodes)
{
group.addChild(std::move(n));
@@ -105,17 +209,17 @@ std::vector<std::unique_ptr<Node>> Parser::parse(const std::string& input)
if(c == '`')
{
size_t start = i + 1;
- size_t end = input.find('`', start);
- if(end != std::string::npos)
+ size_t end = input32.find('`', start);
+ if(end != std::u32string::npos)
{
flush_text();
- std::string content = input.substr(start, end - start);
+ std::u32string content32 = input32.substr(start, end - start);
Macro macro;
macro.name = "code";
Group group;
- group.addChild(std::make_unique<Node>(Text{content}));
+ group.addChild(std::make_unique<Node>(Text{una::utf32to8(content32)}));
macro.arguments.push_back(std::make_unique<Node>(std::move(group)));
nodes.push_back(std::make_unique<Node>(std::move(macro)));
@@ -130,37 +234,37 @@ std::vector<std::unique_ptr<Node>> Parser::parse(const std::string& input)
size_t label_start = i + 1;
size_t j = label_start;
int bracket_bal = 1;
- while(j < input.length() && bracket_bal > 0)
+ while(j < input32.length() && bracket_bal > 0)
{
- if(input[j] == '[') bracket_bal++;
- else if(input[j] == ']') bracket_bal--;
+ if(input32[j] == '[') bracket_bal++;
+ else if(input32[j] == ']') bracket_bal--;
if(bracket_bal > 0) j++;
}
- if(j < input.length() && bracket_bal == 0)
+ if(j < input32.length() && bracket_bal == 0)
{
size_t close_bracket = j;
- if(close_bracket + 1 < input.length() && input[close_bracket + 1] == '(')
+ if(close_bracket + 1 < input32.length() && input32[close_bracket + 1] == '(')
{
size_t url_start = close_bracket + 2;
- size_t url_end = input.find(')', url_start);
- if(url_end != std::string::npos)
+ size_t url_end = input32.find(')', url_start);
+ if(url_end != std::u32string::npos)
{
flush_text();
- std::string label = input.substr(label_start, close_bracket - label_start);
- std::string url = input.substr(url_start, url_end - url_start);
+ std::u32string label32 = input32.substr(label_start, close_bracket - label_start);
+ std::u32string url32 = input32.substr(url_start, url_end - url_start);
Macro macro;
macro.name = "link";
// Arg 1: URL
Group group1;
- group1.addChild(std::make_unique<Node>(Text{url}));
+ group1.addChild(std::make_unique<Node>(Text{una::utf32to8(url32)}));
macro.arguments.push_back(std::make_unique<Node>(std::move(group1)));
// Arg 2: Text (parsed)
Group group2;
- auto sub = parse(label);
+ auto sub = parse(una::utf32to8(label32), prefix_markups, delimited_markups);
for(auto& n : sub) group2.addChild(std::move(n));
macro.arguments.push_back(std::make_unique<Node>(std::move(group2)));
@@ -175,22 +279,22 @@ std::vector<std::unique_ptr<Node>> Parser::parse(const std::string& input)
// Emphasis: * or **
if(c == '*')
{
- bool strong = (i + 1 < input.length() && input[i+1] == '*');
+ bool strong = (i + 1 < input32.length() && input32[i+1] == '*');
size_t start_content = i + (strong ? 2 : 1);
- std::string delim = strong ? "**" : "*";
- size_t end = input.find(delim, start_content);
+ std::u32string delim = strong ? U"**" : U"*";
+ size_t end = input32.find(delim, start_content);
- if(end != std::string::npos)
+ if(end != std::u32string::npos)
{
flush_text();
- std::string content = input.substr(start_content, end - start_content);
+ std::u32string content32 = input32.substr(start_content, end - start_content);
Macro macro;
macro.name = strong ? "strong" : "em";
Group group;
- auto sub = parse(content);
+ auto sub = parse(una::utf32to8(content32), prefix_markups, delimited_markups);
for(auto& n : sub) group.addChild(std::move(n));
macro.arguments.push_back(std::make_unique<Node>(std::move(group)));
@@ -208,4 +312,4 @@ std::vector<std::unique_ptr<Node>> Parser::parse(const std::string& input)
return nodes;
}
-} // namespace macrodown
\ No newline at end of file
+} // namespace macrodown
diff --git a/tests/test_custom_markup.cpp b/tests/test_custom_markup.cpp
new file mode 100644
index 0000000..a01669c
--- /dev/null
+++ b/tests/test_custom_markup.cpp
@@ -0,0 +1,84 @@
+#include <gtest/gtest.h>
+#include "macrodown.h"
+
+using namespace macrodown;
+
+TEST(CustomMarkupTest, PrefixMarkup)
+{
+ MacroDown md;
+ // Define # as prefix markup for 'tag'
+ md.definePrefixMarkup({"#", "tag"});
+ // We also need to define what the 'tag' macro does in HTML
+ md.evaluator().define("tag", {"content"}, "<span class=\"tag\">#%content</span>");
+
+ std::string input = "This is a #test markup.";
+ auto root = md.parse(input);
+ std::string html = md.render(*root);
+
+ EXPECT_EQ(html, "<p>This is a <span class=\"tag\">#test</span> markup.</p>\n");
+}
+
+TEST(CustomMarkupTest, PrefixMarkupPunctuation)
+{
+ MacroDown md;
+ md.definePrefixMarkup({"#", "tag"});
+ md.evaluator().define("tag", {"content"}, "[%content]");
+
+ // Punctuation should end the prefix markup, except for underscore
+ std::string input = "#test. #more_test! #final";
+ std::string html = md.render(*md.parse(input));
+
+ EXPECT_EQ(html, "<p>[test]. [more_test]! [final]</p>\n");
+}
+
+TEST(CustomMarkupTest, DelimitedMarkup)
+{
+ MacroDown md;
+ // Define : as delimited markup for 'highlight'
+ md.defineDelimitedMarkup({":", "highlight"});
+ md.evaluator().define("highlight", {"content"}, "<mark>%content</mark>");
+
+ std::string input = "This is :important: text.";
+ std::string html = md.render(*md.parse(input));
+
+ EXPECT_EQ(html, "<p>This is <mark>important</mark> text.</p>\n");
+}
+
+TEST(CustomMarkupTest, DelimitedMarkupInvalid)
+{
+ MacroDown md;
+ md.defineDelimitedMarkup({":", "highlight"});
+ md.evaluator().define("highlight", {"content"}, "<mark>%content</mark>");
+
+ // Whitespace or punctuation (except _ and -) makes it invalid
+ std::string input = "This is :not valid: because of space. And :this.too: because of dot.";
+ std::string html = md.render(*md.parse(input));
+
+ // Should remain as plain text
+ EXPECT_EQ(html, "<p>This is :not valid: because of space. And :this.too: because of dot.</p>\n");
+}
+
+TEST(CustomMarkupTest, DelimitedMarkupUnderscoreDash)
+{
+ MacroDown md;
+ md.defineDelimitedMarkup({":", "highlight"});
+ md.evaluator().define("highlight", {"content"}, "<mark>%content</mark>");
+
+ std::string input = "This is :valid_with-extra: text.";
+ std::string html = md.render(*md.parse(input));
+
+ EXPECT_EQ(html, "<p>This is <mark>valid_with-extra</mark> text.</p>\n");
+}
+
+TEST(CustomMarkupTest, UnicodeMarkup)
+{
+ MacroDown md;
+ // Multi-byte prefix (e.g., symbol)
+ md.definePrefixMarkup({"§", "section"});
+ md.evaluator().define("section", {"content"}, "Sec. %content");
+
+ std::string input = "See §A.1";
+ std::string html = md.render(*md.parse(input));
+
+ EXPECT_EQ(html, "<p>See Sec. A.1</p>\n");
+}