diff --git a/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java b/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java index 6c671d5e..e526ec96 100644 --- a/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java @@ -3,6 +3,7 @@ import com.eternalcode.formatter.adventure.AdventureUrlPostProcessor; import com.eternalcode.formatter.adventure.TextColorTagResolver; import com.eternalcode.formatter.legacy.Legacy; +import com.eternalcode.formatter.minedown.MineDown; import com.eternalcode.formatter.placeholder.PlaceholderRegistry; import com.eternalcode.formatter.rank.ChatRankProvider; import com.eternalcode.formatter.template.TemplateService; @@ -135,6 +136,11 @@ private TagResolver.Single namePlaceholder(Player sender) { private TagResolver.Single messagePlaceholder(Player sender, String rawMessage) { TagResolver permittedTags = this.providePermittedTags(sender); + + if (this.settings.isMineDownEnabled()) { + rawMessage = MineDown.mineDownToAdventure(rawMessage, permittedTags); + } + rawMessage = Legacy.legacyToAdventure(rawMessage, permission -> sender.hasPermission(permission)); Component componentMessage = EMPTY_MESSAGE_DESERIALIZER.deserialize(rawMessage, permittedTags); return Placeholder.component("message", componentMessage); diff --git a/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatSettings.java b/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatSettings.java index db5805fe..4a5ba28d 100644 --- a/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatSettings.java +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatSettings.java @@ -4,6 +4,8 @@ public interface ChatSettings { boolean isReceiveUpdates(); + boolean isMineDownEnabled(); + String getRawFormat(String rank); } diff --git a/chatformatter-core/src/main/java/com/eternalcode/formatter/adventure/AdventureUrlPostProcessor.java b/chatformatter-core/src/main/java/com/eternalcode/formatter/adventure/AdventureUrlPostProcessor.java index 77a8ceb2..e4781927 100644 --- a/chatformatter-core/src/main/java/com/eternalcode/formatter/adventure/AdventureUrlPostProcessor.java +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/adventure/AdventureUrlPostProcessor.java @@ -1,7 +1,6 @@ package com.eternalcode.formatter.adventure; import java.util.function.UnaryOperator; -import java.util.regex.Pattern; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextReplacementConfig; import net.kyori.adventure.text.event.ClickEvent; @@ -9,10 +8,8 @@ public class AdventureUrlPostProcessor implements UnaryOperator { - private static final Pattern URL_PATTERN = Pattern.compile("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()!@:%_\\+.~#?&\\/\\/=]*)"); - public static final @NotNull TextReplacementConfig CLICKABLE_URL_CONFIG = TextReplacementConfig.builder() - .match(URL_PATTERN) + .match(Urls.URL_PATTERN) .replacement(url -> url.clickEvent(ClickEvent.openUrl(url.content()))) .build(); diff --git a/chatformatter-core/src/main/java/com/eternalcode/formatter/adventure/Urls.java b/chatformatter-core/src/main/java/com/eternalcode/formatter/adventure/Urls.java new file mode 100644 index 00000000..86956601 --- /dev/null +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/adventure/Urls.java @@ -0,0 +1,41 @@ +package com.eternalcode.formatter.adventure; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.UnaryOperator; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public final class Urls { + + public static final Pattern URL_PATTERN = Pattern.compile("https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()!@:%_\\+.~#?&\\/\\/=]*)"); + + // Private use characters, so no formatting syntax can match them or the index between them. + private static final char MASK_START = ''; + private static final char MASK_END = ''; + private static final Pattern MASK_PATTERN = Pattern.compile(MASK_START + "(\\d+)" + MASK_END); + + private Urls() { + } + + /** + * Applies the transformation to the input while keeping every URL in it unchanged, + * so formatting syntax such as {@code &b} or {@code __} inside a URL is not translated. + */ + public static String preserving(String input, UnaryOperator transformation) { + List urls = new ArrayList<>(); + + String masked = URL_PATTERN.matcher(input).replaceAll(match -> { + urls.add(match.group()); + return MASK_START + String.valueOf(urls.size() - 1) + MASK_END; + }); + + String transformed = transformation.apply(masked); + + return MASK_PATTERN.matcher(transformed).replaceAll(match -> { + int index = Integer.parseInt(match.group(1)); + return Matcher.quoteReplacement(index < urls.size() ? urls.get(index) : match.group()); + }); + } + +} diff --git a/chatformatter-core/src/main/java/com/eternalcode/formatter/config/PluginConfig.java b/chatformatter-core/src/main/java/com/eternalcode/formatter/config/PluginConfig.java index 2243fa79..cf5f610a 100644 --- a/chatformatter-core/src/main/java/com/eternalcode/formatter/config/PluginConfig.java +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/config/PluginConfig.java @@ -24,6 +24,16 @@ public class PluginConfig implements ChatSettings, TemplateRepository { @Description({ " ", "# Do you want to receive updates about new versions of ChatFormatter?" }) public boolean receiveUpdates = true; + @Description({ " ", "# Do you want to enable MineDown syntax in player messages? (Markdown-like formatting for Minecraft)", + "# It works on top of MiniMessage and legacy colors, so all of them can be mixed in one message.", + "# Supported: **bold**, ##italic##, __underlined__, ~~strikethrough~~, ??obfuscated??, &red&, &#ff0000&,", + "# &#ff0000-#0000ff& (gradient), &rainbow&, [text](https://url), [text](/command),", + "# [text](red bold hover=Hover text), [text](suggest_command=/msg Steve), [text](copy_to_clipboard=...),", + "# [text](insert=...), [text](font=minecraft:uniform)", + "# Every MineDown element requires the same permission as its MiniMessage equivalent (e.g. **bold** -> chatformatter.decorations.bold).", + "# See https://github.com/Phoenix616/MineDown for more information." }) + public boolean mineDownSupport = false; + @Description({ " ", "# Chat format for ranks (Vault) Support mini-messages and legacy colors" }) @Description({ " ", "# We're recommending to use webui for mini-messages: https://webui.adventure.kyori.net/" }) @@ -93,6 +103,11 @@ public boolean isReceiveUpdates() { return this.receiveUpdates; } + @Override + public boolean isMineDownEnabled() { + return this.mineDownSupport; + } + @Override public String getRawFormat(String rank) { return this.format.getOrDefault(rank, this.defaultFormat); diff --git a/chatformatter-core/src/main/java/com/eternalcode/formatter/legacy/Legacy.java b/chatformatter-core/src/main/java/com/eternalcode/formatter/legacy/Legacy.java index 499c0062..372cdad7 100644 --- a/chatformatter-core/src/main/java/com/eternalcode/formatter/legacy/Legacy.java +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/legacy/Legacy.java @@ -1,10 +1,12 @@ package com.eternalcode.formatter.legacy; +import com.eternalcode.formatter.adventure.Urls; import com.google.common.collect.ImmutableMap; import java.util.Set; import java.util.function.Predicate; import java.util.Map; +import java.util.regex.Matcher; import java.util.regex.Pattern; import org.jetbrains.annotations.VisibleForTesting; @@ -14,6 +16,9 @@ public final class Legacy { private static final Pattern HEX_LEGACY_PATTERN = Pattern.compile("(?i)&#([0-9A-F]{6})"); private static final Pattern HEX_LEGACY_VANILLA_PATTERN = Pattern.compile("(?i)&x(&[0-9A-F]){6}"); + // Private use character that temporarily replaces the '&' of a hex color that is not allowed + private static final char HIDDEN_AMPERSAND = '\uE002'; + private static final Set COLORS = Set.of( '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f' @@ -86,13 +91,27 @@ public static String legacyToAdventure(String input) { } public static String legacyToAdventure(String input, Predicate hasPermission) { - String result = clearSection(input); - result = HEX_LEGACY_VANILLA_PATTERN.matcher(result).replaceAll(matchResult -> { + return Urls.preserving(clearSection(input), text -> translate(text, hasPermission)); + } + + private static String translate(String input, Predicate hasPermission) { + boolean hasHexPermission = hasPermissionForHex(hasPermission); + + String result = HEX_LEGACY_VANILLA_PATTERN.matcher(input).replaceAll(matchResult -> { + if (!hasHexPermission) { + // Hide the '&' so the single color codes of this hex color are not translated below + return Matcher.quoteReplacement(matchResult.group().replace('&', HIDDEN_AMPERSAND)); + } + String hexColor = matchResult.group().replace("&x", "").replace("&", ""); return "<#" + hexColor + ">"; }); result = HEX_LEGACY_PATTERN.matcher(result).replaceAll(matchResult -> { + if (!hasHexPermission) { + return Matcher.quoteReplacement(matchResult.group()); + } + String hex = matchResult.group(1); return "<#" + hex + ">"; }); @@ -106,7 +125,12 @@ public static String legacyToAdventure(String input, Predicate hasPermis return "&" + color; }); - return result; + return result.replace(HIDDEN_AMPERSAND, '&'); + } + + // Hex colors are allowed the same way as <#rrggbb> in MiniMessage, which requires chatformatter.color.* + private static boolean hasPermissionForHex(Predicate hasPermission) { + return hasPermission.test("chatformatter.*") || hasPermission.test("chatformatter.color.*"); } private static boolean hasPermissionForLegacyCode(Predicate hasPermission, char code) { diff --git a/chatformatter-core/src/main/java/com/eternalcode/formatter/minedown/MineDown.java b/chatformatter-core/src/main/java/com/eternalcode/formatter/minedown/MineDown.java new file mode 100644 index 00000000..0e680d7a --- /dev/null +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/minedown/MineDown.java @@ -0,0 +1,277 @@ +package com.eternalcode.formatter.minedown; + +import com.eternalcode.formatter.adventure.Urls; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.MatchResult; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; + +/** + * Translates MineDown syntax (https://github.com/Phoenix616/MineDown) into MiniMessage tags, + * so it can be mixed freely with MiniMessage and legacy colors. + *

+ * A MineDown element is translated only when every MiniMessage tag it needs is present in the given resolver, + * so MineDown uses exactly the same permissions as MiniMessage. Anything else is left untouched as plain text. + */ +public final class MineDown { + + private static final List SIMPLE_FORMATS = List.of( + new SimpleFormat("**", "bold"), + new SimpleFormat("##", "italic"), + new SimpleFormat("__", "underlined"), + new SimpleFormat("~~", "strikethrough"), + new SimpleFormat("??", "obfuscated") + ); + + private static final List DECORATIONS = List.of("bold", "italic", "underlined", "strikethrough", "obfuscated"); + + private static final Pattern COLOR_PATTERN = Pattern.compile("&(#[0-9a-fA-F]{6}(?:-#[0-9a-fA-F]{6})*|[a-zA-Z_]{2,})&"); + private static final Pattern HEX_PATTERN = Pattern.compile("#[0-9a-fA-F]{6}"); + private static final Pattern LINK_PATTERN = Pattern.compile("\\[([^\\[\\]]+)]\\(([^()]+)\\)"); + private static final Pattern PROPERTY_START_PATTERN = Pattern.compile("(?:^|\\s)(\\w+)="); + // Private use characters, so no formatting syntax can match them or the index between them. + private static final char LINK_TAG_START = '\uE003'; + private static final char LINK_TAG_END = '\uE004'; + private static final Pattern LINK_TAG_PATTERN = Pattern.compile(LINK_TAG_START + "(\\d+)" + LINK_TAG_END); + private static final Pattern KEY_PATTERN = Pattern.compile("[a-z0-9_.-]+(?::[a-z0-9_./-]+)?"); + + private MineDown() { + } + + public static String mineDownToAdventure(String input, TagResolver permittedTags) { + List linkTags = new ArrayList<>(); + + String result = Urls.preserving(input, text -> translateColors(text, permittedTags)); + result = translateLinks(result, permittedTags, linkTags); + result = Urls.preserving(result, text -> translateSimpleFormats(text, permittedTags)); + + return restoreLinkTags(result, linkTags); + } + + private static String translateSimpleFormats(String input, TagResolver permittedTags) { + String result = input; + + for (SimpleFormat format : SIMPLE_FORMATS) { + result = format.pattern().matcher(result).replaceAll(match -> { + if (!permittedTags.has(format.tag())) { + return Matcher.quoteReplacement(match.group()); + } + + return Matcher.quoteReplacement("<" + format.tag() + ">" + match.group(1) + ""); + }); + } + + return result; + } + + private static String translateColors(String input, TagResolver permittedTags) { + return COLOR_PATTERN.matcher(input).replaceAll(match -> { + String color = match.group(1).toLowerCase(Locale.ROOT); + + Optional tag = color.contains("-") + ? gradientTag(color, permittedTags) + : colorTag(color, permittedTags); + + return Matcher.quoteReplacement(tag.orElse(match.group())); + }); + } + + private static Optional gradientTag(String colors, TagResolver permittedTags) { + if (!permittedTags.has("gradient")) { + return Optional.empty(); + } + + return Optional.of(""); + } + + private static Optional colorTag(String color, TagResolver permittedTags) { + if (color.equals("rainbow")) { + return permittedTags.has("rainbow") ? Optional.of("") : Optional.empty(); + } + + boolean isColor = HEX_PATTERN.matcher(color).matches() || NamedTextColor.NAMES.value(color) != null; + + if (!isColor || !permittedTags.has(color)) { + return Optional.empty(); + } + + return Optional.of("<" + color + ">"); + } + + /** + * Opening tags are hidden behind {@link #LINK_TAG_PATTERN} markers, so their quoted arguments + * (hover text, URLs, commands) are not changed by the simple formats translated afterwards. + */ + private static String translateLinks(String input, TagResolver permittedTags, List linkTags) { + return LINK_PATTERN.matcher(input).replaceAll(match -> { + Optional> tags = parseLinkDefinition(match.group(2).trim(), permittedTags); + + if (tags.isEmpty() || tags.get().isEmpty()) { + return Matcher.quoteReplacement(match.group()); + } + + StringBuilder builder = new StringBuilder(); + + for (String tag : tags.get()) { + linkTags.add("<" + tag + ">"); + builder.append(LINK_TAG_START).append(linkTags.size() - 1).append(LINK_TAG_END); + } + + builder.append(match.group(1)); + + for (int i = tags.get().size() - 1; i >= 0; i--) { + builder.append("'); + } + + return Matcher.quoteReplacement(builder.toString()); + }); + } + + private static String restoreLinkTags(String input, List linkTags) { + return LINK_TAG_PATTERN.matcher(input).replaceAll(match -> { + int index = Integer.parseInt(match.group(1)); + return Matcher.quoteReplacement(index < linkTags.size() ? linkTags.get(index) : match.group()); + }); + } + + /** + * Parses the part in parentheses of {@code [text](definition)}. + * Returns empty when any part of the definition is unknown or not permitted, so the link stays as plain text. + */ + private static Optional> parseLinkDefinition(String definition, TagResolver permittedTags) { + List tags = new ArrayList<>(); + + List properties = PROPERTY_START_PATTERN.matcher(definition).results().toList(); + int propertiesStart = properties.isEmpty() ? definition.length() : properties.get(0).start(); + + for (String shorthand : definition.substring(0, propertiesStart).trim().split("\\s+")) { + if (shorthand.isEmpty()) { + continue; + } + + Optional tag = shorthandTag(shorthand, permittedTags); + + if (tag.isEmpty()) { + return Optional.empty(); + } + + tags.add(tag.get()); + } + + for (int i = 0; i < properties.size(); i++) { + String key = properties.get(i).group(1).toLowerCase(Locale.ROOT); + int valueEnd = i + 1 < properties.size() ? properties.get(i + 1).start() : definition.length(); + String value = definition.substring(properties.get(i).end(), valueEnd).trim(); + + Optional> propertyTags = propertyTags(key, value, permittedTags); + + if (propertyTags.isEmpty()) { + return Optional.empty(); + } + + tags.addAll(propertyTags.get()); + } + + return Optional.of(tags); + } + + private static Optional shorthandTag(String shorthand, TagResolver permittedTags) { + if (Urls.URL_PATTERN.matcher(shorthand).matches()) { + return clickTag("open_url", shorthand, permittedTags); + } + + if (shorthand.startsWith("/")) { + return clickTag("run_command", shorthand, permittedTags); + } + + String lowerCase = shorthand.toLowerCase(Locale.ROOT); + + if (DECORATIONS.contains(lowerCase)) { + return permittedTags.has(lowerCase) ? Optional.of(lowerCase) : Optional.empty(); + } + + return colorTag(lowerCase, permittedTags).map(MineDown::stripBrackets); + } + + private static Optional> propertyTags(String key, String value, TagResolver permittedTags) { + if (value.isEmpty()) { + return Optional.empty(); + } + + switch (key) { + case "color": + case "colour": + return colorTag(value.toLowerCase(Locale.ROOT), permittedTags).map(tag -> List.of(stripBrackets(tag))); + case "format": + List decorations = new ArrayList<>(); + + for (String decoration : value.toLowerCase(Locale.ROOT).split("\\s*,\\s*")) { + if (!DECORATIONS.contains(decoration) || !permittedTags.has(decoration)) { + return Optional.empty(); + } + + decorations.add(decoration); + } + + return Optional.of(decorations); + case "font": + String font = value.toLowerCase(Locale.ROOT); + + if (!KEY_PATTERN.matcher(font).matches() || !permittedTags.has("font")) { + return Optional.empty(); + } + + return Optional.of(List.of("font:" + font)); + case "hover": + return permittedTags.has("hover") + ? Optional.of(List.of("hover:show_text:" + quote(value))) + : Optional.empty(); + case "insert": + return permittedTags.has("insert") + ? Optional.of(List.of("insert:" + quote(value))) + : Optional.empty(); + case "open_url": + case "run_command": + case "suggest_command": + case "copy_to_clipboard": + return clickTag(key, value, permittedTags).map(List::of); + default: + return Optional.empty(); + } + } + + private static Optional clickTag(String action, String value, TagResolver permittedTags) { + if (!permittedTags.has("click")) { + return Optional.empty(); + } + + return Optional.of("click:" + action + ":" + quote(value)); + } + + private static String quote(String value) { + return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"; + } + + private static String stripBrackets(String tag) { + return tag.substring(1, tag.length() - 1); + } + + private static String tagName(String tag) { + int separator = tag.indexOf(':'); + return separator == -1 ? tag : tag.substring(0, separator); + } + + private record SimpleFormat(String marker, String tag, Pattern pattern) { + + SimpleFormat(String marker, String tag) { + this(marker, tag, Pattern.compile(Pattern.quote(marker) + "(.+?)" + Pattern.quote(marker))); + } + + } + +} diff --git a/chatformatter-core/src/test/java/com/eternalcode/formatter/legacy/LegacyTest.java b/chatformatter-core/src/test/java/com/eternalcode/formatter/legacy/LegacyTest.java index a704df4c..15f03ed3 100644 --- a/chatformatter-core/src/test/java/com/eternalcode/formatter/legacy/LegacyTest.java +++ b/chatformatter-core/src/test/java/com/eternalcode/formatter/legacy/LegacyTest.java @@ -1,5 +1,6 @@ package com.eternalcode.formatter.legacy; +import java.util.function.Predicate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -32,4 +33,24 @@ void testLegacyHexToAdventure() { assertEquals("<#ccdd77>SIEMA <#775544>test!", result); } + @Test + @DisplayName("Should not translate legacy codes inside URLs") + void testUrlsArePreserved() { + String input = "&cVisit https://example.com/?x=1&b=2&c=3 &anow"; + + String result = Legacy.legacyToAdventure(input); + + assertEquals("Visit https://example.com/?x=1&b=2&c=3 now", result); + } + + @Test + @DisplayName("Should require chatformatter.color.* for hex colors") + void testHexRequiresPermission() { + String input = "₸d3a &x&c&c&d&d&7&7b &cc"; + Predicate onlyRed = "chatformatter.color.red"::equals; + + assertEquals("₸d3a &x&c&c&d&d&7&7b c", Legacy.legacyToAdventure(input, onlyRed)); + assertEquals("<#8376d3>a <#ccdd77>b c", Legacy.legacyToAdventure(input, "chatformatter.color.*"::equals)); + } + } diff --git a/chatformatter-core/src/test/java/com/eternalcode/formatter/minedown/MineDownTest.java b/chatformatter-core/src/test/java/com/eternalcode/formatter/minedown/MineDownTest.java new file mode 100644 index 00000000..f0530c2e --- /dev/null +++ b/chatformatter-core/src/test/java/com/eternalcode/formatter/minedown/MineDownTest.java @@ -0,0 +1,146 @@ +package com.eternalcode.formatter.minedown; + +import com.eternalcode.formatter.adventure.TextColorTagResolver; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.kyori.adventure.text.minimessage.tag.standard.StandardTags; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MineDownTest { + + private static final TagResolver ALL = TagResolver.standard(); + private static final TagResolver NONE = TagResolver.empty(); + + @Test + @DisplayName("Should translate simple formatting to MiniMessage tags") + void testSimpleFormatting() { + String result = MineDown.mineDownToAdventure("**bold** ##italic## __under__ ~~strike~~ ??obf??", ALL); + + assertEquals("bold italic under " + + "strike obf", result); + } + + @Test + @DisplayName("Should translate MineDown colors, gradients and rainbow") + void testColors() { + String result = MineDown.mineDownToAdventure("&red&a &#FF0000&b &#ff0000-#0000ff&c &rainbow&d", ALL); + + assertEquals("a <#ff0000>b c d", result); + } + + @Test + @DisplayName("Should leave text untouched without permissions") + void testNoPermissions() { + String input = "**bold** &red&a &rainbow&b [link](https://example.com) [x](red)"; + + assertEquals(input, MineDown.mineDownToAdventure(input, NONE)); + } + + @Test + @DisplayName("Should respect per-tag permissions") + void testPartialPermissions() { + TagResolver permitted = TagResolver.resolver( + StandardTags.decorations(TextDecoration.BOLD), + TextColorTagResolver.of(NamedTextColor.GOLD) + ); + + String result = MineDown.mineDownToAdventure("**a** ##b## &gold&c &red&d", permitted); + + assertEquals("a ##b## c &red&d", result); + } + + @Test + @DisplayName("Should translate links with shorthand and properties") + void testLinks() { + String result = MineDown.mineDownToAdventure( + "[site](https://example.com/?a=b) [cmd](/spawn) [styled](red bold hover=It's me) [x](suggest_command=/msg Steve)", + ALL + ); + + assertEquals( + "site " + + "cmd " + + "styled " + + "x", + result + ); + } + + @Test + @DisplayName("Should leave the whole link untouched when any part is not permitted or unknown") + void testLinkAllOrNothing() { + TagResolver permitted = TagResolver.resolver(StandardTags.color(), StandardTags.hoverEvent()); + + assertEquals("[x](red https://example.com)", MineDown.mineDownToAdventure("[x](red https://example.com)", permitted)); + assertEquals("[x](sprite=abc)", MineDown.mineDownToAdventure("[x](sprite=abc)", ALL)); + assertEquals("[x](font=Bad Key)", MineDown.mineDownToAdventure("[x](font=Bad Key)", ALL)); + assertEquals("x", MineDown.mineDownToAdventure("[x](red hover=hi)", permitted)); + } + + @Test + @DisplayName("Should mix MineDown with MiniMessage tags") + void testMixedWithMiniMessage() { + String translated = MineDown.mineDownToAdventure("**hi** [site](https://example.com)", ALL); + Component component = MiniMessage.miniMessage().deserialize(translated); + + Component expected = Component.text() + .append(Component.text("hi", NamedTextColor.RED, TextDecoration.BOLD)) + .append(Component.text(" ")) + .append(Component.text("site").clickEvent(ClickEvent.openUrl("https://example.com"))) + .build(); + + assertEquals(expected.compact(), component.compact()); + } + + @Test + @DisplayName("Should not break on malformed input") + void testMalformedInput() { + String input = "hi &foo& ** [x]( ) [](red) &&"; + + assertEquals(input, MineDown.mineDownToAdventure(input, ALL)); + } + + @Test + @DisplayName("Should not translate MineDown syntax inside URLs") + void testUrlsArePreserved() { + String result = MineDown.mineDownToAdventure( + "**see** https://example.com/a__b__c?x=&red&y [docs](https://example.com/__init__)", + ALL + ); + + assertEquals( + "see https://example.com/a__b__c?x=&red&y " + + "docs", + result + ); + } + + @Test + @DisplayName("Should format text that contains a URL") + void testFormattingAroundUrl() { + String result = MineDown.mineDownToAdventure("**see https://example.com**", ALL); + + assertEquals("see https://example.com", result); + } + + @Test + @DisplayName("Should not translate simple formats inside quoted link arguments") + void testQuotedLinkArgumentsArePreserved() { + assertEquals( + "a b**", + MineDown.mineDownToAdventure("[a](hover=x**) b**", ALL) + ); + assertEquals( + "a", + MineDown.mineDownToAdventure("**[a](hover=**hi**)**", ALL) + ); + } + +}