BareGit

Implement custom prefix and delimited markups

Author: MetroWind <chris.corsair@gmail.com>
Date: Sat Jan 24 13:01:08 2026 -0800
Commit: ed37bff1e1554a2b8125a2979d5180f7cccd17f0

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");
+}