From 8aee8eef828ecfe577645bd31fc552f069b9390a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:18:00 +0000 Subject: [PATCH 1/4] Initial plan From bb62e3989daab7708046652369e637fe77eb17c5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:24:28 +0000 Subject: [PATCH 2/4] Add MineDown support for Markdown-like formatting in player messages Co-authored-by: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> --- buildSrc/src/main/kotlin/eternalcode.java.gradle.kts | 1 + chatformatter-core/build.gradle.kts | 4 ++++ .../com/eternalcode/formatter/ChatHandlerImpl.java | 10 +++++++++- .../java/com/eternalcode/formatter/ChatSettings.java | 2 ++ .../com/eternalcode/formatter/config/PluginConfig.java | 9 +++++++++ gradlew | 0 6 files changed, 25 insertions(+), 1 deletion(-) mode change 100644 => 100755 gradlew diff --git a/buildSrc/src/main/kotlin/eternalcode.java.gradle.kts b/buildSrc/src/main/kotlin/eternalcode.java.gradle.kts index 927d8615..41840b28 100644 --- a/buildSrc/src/main/kotlin/eternalcode.java.gradle.kts +++ b/buildSrc/src/main/kotlin/eternalcode.java.gradle.kts @@ -14,6 +14,7 @@ repositories { maven { url = uri("https://jitpack.io") } maven { url = uri("https://repo.eternalcode.pl/releases") } maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/") + maven { url = uri("https://repo.minebench.de/") } } java { diff --git a/chatformatter-core/build.gradle.kts b/chatformatter-core/build.gradle.kts index 1da97d7c..898ac15a 100644 --- a/chatformatter-core/build.gradle.kts +++ b/chatformatter-core/build.gradle.kts @@ -33,6 +33,9 @@ dependencies { // GitCheck implementation("com.eternalcode:gitcheck:1.0.0") + // MineDown + implementation("de.themoep:minedown-adventure:1.7.6-SNAPSHOT") + // JUnit 5 testImplementation(platform("org.junit:junit-bom:6.0.2")) testImplementation("org.junit.jupiter:junit-jupiter-api") @@ -72,6 +75,7 @@ tasks { "net.kyori", "org.bstats", "org.json", + "de.themoep.minedown", ).forEach { pack -> relocate(pack, "$prefix.$pack") } 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 0b82fb04..b0a170a6 100644 --- a/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java @@ -1,6 +1,7 @@ package com.eternalcode.formatter; import com.eternalcode.formatter.adventure.AdventureUrlPostProcessor; +import de.themoep.minedown.adventure.MineDown; import java.util.Optional; import net.kyori.adventure.text.serializer.json.JSONOptions; import static net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer.legacySection; @@ -137,7 +138,14 @@ private TagResolver.Single namePlaceholder(Player sender) { private TagResolver.Single messagePlaceholder(Player sender, String rawMessage) { TagResolver permittedTags = this.providePermittedTags(sender); rawMessage = Legacy.legacyToAdventure(rawMessage, permission -> sender.hasPermission(permission)); - Component componentMessage = EMPTY_MESSAGE_DESERIALIZER.deserialize(rawMessage, permittedTags); + + Component componentMessage; + if (this.settings.isMineDownEnabled()) { + componentMessage = MineDown.parse(rawMessage); + } else { + 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/config/PluginConfig.java b/chatformatter-core/src/main/java/com/eternalcode/formatter/config/PluginConfig.java index 90ca6989..ab26642e 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 @@ -23,6 +23,10 @@ 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 support? (Markdown-like formatting for Minecraft)", + "# 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/" }) @@ -89,6 +93,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/gradlew b/gradlew old mode 100644 new mode 100755 From 62ace9fc2b3a2998f8659ce3e68b6439b167bd10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:26:04 +0000 Subject: [PATCH 3/4] Address review: add permission checks and fix legacy color handling for MineDown Co-authored-by: Jakubk15 <77227023+Jakubk15@users.noreply.github.com> --- .../formatter/ChatHandlerImpl.java | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) 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 b0a170a6..f55777fc 100644 --- a/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java @@ -2,6 +2,7 @@ import com.eternalcode.formatter.adventure.AdventureUrlPostProcessor; import de.themoep.minedown.adventure.MineDown; +import de.themoep.minedown.adventure.MineDownParser; import java.util.Optional; import net.kyori.adventure.text.serializer.json.JSONOptions; import static net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer.legacySection; @@ -136,13 +137,27 @@ private TagResolver.Single namePlaceholder(Player sender) { } private TagResolver.Single messagePlaceholder(Player sender, String rawMessage) { - TagResolver permittedTags = this.providePermittedTags(sender); - rawMessage = Legacy.legacyToAdventure(rawMessage, permission -> sender.hasPermission(permission)); - Component componentMessage; + if (this.settings.isMineDownEnabled()) { - componentMessage = MineDown.parse(rawMessage); + MineDown mineDown = new MineDown(rawMessage); + + if (!sender.hasPermission(PERMISSION_ALL)) { + if (!sender.hasPermission("chatformatter.decorations.*")) { + mineDown.disable(MineDownParser.Option.SIMPLE_FORMATTING); + } + if (!(sender.hasPermission("chatformatter.hover") && sender.hasPermission("chatformatter.click"))) { + mineDown.disable(MineDownParser.Option.ADVANCED_FORMATTING); + } + if (!sender.hasPermission("chatformatter.color.*")) { + mineDown.disable(MineDownParser.Option.LEGACY_COLORS); + } + } + + componentMessage = mineDown.toComponent(); } else { + TagResolver permittedTags = this.providePermittedTags(sender); + rawMessage = Legacy.legacyToAdventure(rawMessage, permission -> sender.hasPermission(permission)); componentMessage = EMPTY_MESSAGE_DESERIALIZER.deserialize(rawMessage, permittedTags); } From bcbf8ade5027423e6aa30ce70766fdaf3e24477d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20K=C4=99dziora?= <77227023+Jakubk15@users.noreply.github.com> Date: Fri, 25 Sep 2026 19:04:51 +0200 Subject: [PATCH 4/4] Translate MineDown syntax into MiniMessage instead of replacing it MineDown now works as an add-on to MiniMessage: its syntax is rewritten into MiniMessage tags before the normal pipeline runs, so MineDown, MiniMessage and legacy colors can be mixed in one message. Each MineDown element is translated only when the sender is permitted to use the matching MiniMessage tag, so both share one permission model. The minedown-adventure SNAPSHOT dependency and its repository are removed. Also: - Keep URLs unchanged by legacy codes and MineDown formatting. - Require chatformatter.color.* for legacy hex colors (&#rrggbb, &x...). - Leave quoted link arguments untouched by MineDown simple formats. Co-Authored-By: Claude Opus 5.5 --- .../main/kotlin/eternalcode.java.gradle.kts | 1 - chatformatter-core/build.gradle.kts | 4 - .../formatter/ChatHandlerImpl.java | 27 +- .../adventure/AdventureUrlPostProcessor.java | 5 +- .../eternalcode/formatter/adventure/Urls.java | 41 +++ .../formatter/config/PluginConfig.java | 10 +- .../eternalcode/formatter/legacy/Legacy.java | 30 +- .../formatter/minedown/MineDown.java | 277 ++++++++++++++++++ .../formatter/legacy/LegacyTest.java | 21 ++ .../formatter/minedown/MineDownTest.java | 146 +++++++++ 10 files changed, 526 insertions(+), 36 deletions(-) create mode 100644 chatformatter-core/src/main/java/com/eternalcode/formatter/adventure/Urls.java create mode 100644 chatformatter-core/src/main/java/com/eternalcode/formatter/minedown/MineDown.java create mode 100644 chatformatter-core/src/test/java/com/eternalcode/formatter/minedown/MineDownTest.java diff --git a/buildSrc/src/main/kotlin/eternalcode.java.gradle.kts b/buildSrc/src/main/kotlin/eternalcode.java.gradle.kts index 79e81b77..31feb09f 100644 --- a/buildSrc/src/main/kotlin/eternalcode.java.gradle.kts +++ b/buildSrc/src/main/kotlin/eternalcode.java.gradle.kts @@ -14,7 +14,6 @@ repositories { maven { url = uri("https://jitpack.io") } maven { url = uri("https://repo.eternalcode.pl/releases") } maven { url = uri("https://s01.oss.sonatype.org/content/repositories/snapshots/") } - maven { url = uri("https://repo.minebench.de/") } } java { diff --git a/chatformatter-core/build.gradle.kts b/chatformatter-core/build.gradle.kts index fa78c4c0..44d9ed7a 100644 --- a/chatformatter-core/build.gradle.kts +++ b/chatformatter-core/build.gradle.kts @@ -36,9 +36,6 @@ dependencies { // GitCheck implementation("com.eternalcode:gitcheck:1.0.0") - // MineDown - implementation("de.themoep:minedown-adventure:1.7.6-SNAPSHOT") - // JUnit 5 testImplementation(platform("org.junit:junit-bom:6.1.2")) testImplementation("org.junit.jupiter:junit-jupiter-api") @@ -78,7 +75,6 @@ tasks { "net.kyori", "org.bstats", "org.json", - "de.themoep.minedown", ).forEach { pack -> relocate(pack, "$prefix.$pack") } 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 0baf271e..e526ec96 100644 --- a/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java +++ b/chatformatter-core/src/main/java/com/eternalcode/formatter/ChatHandlerImpl.java @@ -1,10 +1,9 @@ package com.eternalcode.formatter; import com.eternalcode.formatter.adventure.AdventureUrlPostProcessor; -import de.themoep.minedown.adventure.MineDown; -import de.themoep.minedown.adventure.MineDownParser; 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; @@ -136,30 +135,14 @@ private TagResolver.Single namePlaceholder(Player sender) { } private TagResolver.Single messagePlaceholder(Player sender, String rawMessage) { - Component componentMessage; + TagResolver permittedTags = this.providePermittedTags(sender); if (this.settings.isMineDownEnabled()) { - MineDown mineDown = new MineDown(rawMessage); - - if (!sender.hasPermission(PERMISSION_ALL)) { - if (!sender.hasPermission("chatformatter.decorations.*")) { - mineDown.disable(MineDownParser.Option.SIMPLE_FORMATTING); - } - if (!(sender.hasPermission("chatformatter.hover") && sender.hasPermission("chatformatter.click"))) { - mineDown.disable(MineDownParser.Option.ADVANCED_FORMATTING); - } - if (!sender.hasPermission("chatformatter.color.*")) { - mineDown.disable(MineDownParser.Option.LEGACY_COLORS); - } - } - - componentMessage = mineDown.toComponent(); - } else { - TagResolver permittedTags = this.providePermittedTags(sender); - rawMessage = Legacy.legacyToAdventure(rawMessage, permission -> sender.hasPermission(permission)); - componentMessage = EMPTY_MESSAGE_DESERIALIZER.deserialize(rawMessage, permittedTags); + 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/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 3f47fa84..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,8 +24,14 @@ 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 support? (Markdown-like formatting for Minecraft)", - "# See https://github.com/Phoenix616/MineDown for more information." }) + @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; 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) + ); + } + +}