Skip to content

Commit 5f846c4

Browse files
committed
feature(minimessage): Presets
1 parent c6e2b48 commit 5f846c4

5 files changed

Lines changed: 325 additions & 2 deletions

File tree

text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessage.java

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,19 @@
2323
*/
2424
package net.kyori.adventure.text.minimessage;
2525

26+
import java.util.Objects;
2627
import java.util.function.Consumer;
2728
import java.util.function.UnaryOperator;
2829
import net.kyori.adventure.builder.AbstractBuilder;
2930
import net.kyori.adventure.pointer.Pointered;
3031
import net.kyori.adventure.text.Component;
32+
import net.kyori.adventure.text.TextComponent;
33+
import net.kyori.adventure.text.VirtualComponent;
3134
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver;
35+
import net.kyori.adventure.text.minimessage.tag.standard.StandardTags;
3236
import net.kyori.adventure.text.minimessage.tree.Node;
3337
import net.kyori.adventure.text.serializer.ComponentSerializer;
38+
import net.kyori.adventure.util.Index;
3439
import net.kyori.adventure.util.PlatformAPI;
3540
import org.jetbrains.annotations.ApiStatus;
3641
import org.jspecify.annotations.Nullable;
@@ -54,6 +59,134 @@ static MiniMessage miniMessage() {
5459
return MiniMessageImpl.Instances.INSTANCE;
5560
}
5661

62+
/**
63+
* Gets an instance based on a specific preset.
64+
*
65+
* @param preset the preset to use
66+
* @return an instance
67+
* @since 5.0.0
68+
*/
69+
static MiniMessage miniMessage(final Preset preset) {
70+
Objects.requireNonNull(preset, "preset");
71+
return MiniMessageImpl.Instances.PRESETS.get(preset);
72+
}
73+
74+
/**
75+
* A variety of presets for configuring MiniMessage.
76+
*
77+
* <p>
78+
* These presets can be used to get a default confuguration for MiniMessage by
79+
* using the {@link #miniMessage(Preset)} method.
80+
* </p>
81+
*
82+
* <p>
83+
* Alternatively, you can get a pre-configured builder based on a preset.
84+
* This can be used to make your own custom MiniMessage instance whilst still keeping
85+
* any configuration provided by a preset, such as post-processing.
86+
* An example of how to do this is provided below.
87+
* </p>
88+
* <pre>
89+
* {@code
90+
* // Obtain the pre-configured builder.
91+
* final MiniMessage.Builder builder = MiniMessage.builder(MiniMessage.Preset.NON_INTERACTABLE);
92+
*
93+
* // Add your own tags or override other settings if needed.
94+
* builder.editTags(tags -> {
95+
* tags
96+
* .resolver(new MyCustomTagResolver())
97+
* .resolver(fetchExternalTags());
98+
* });
99+
*
100+
* // Then build your MiniMessage instance!
101+
* final MiniMessage myMiniMessageInstance = builder.build();
102+
* }
103+
* </pre>
104+
*
105+
* @see #miniMessage(Preset)
106+
* @see #builder(Preset)
107+
* @since 5.0.0
108+
*/
109+
enum Preset implements Consumer<Builder> {
110+
/**
111+
* The default preset, containing all features of MiniMessage.
112+
*
113+
* @since 5.0.0
114+
*/
115+
DEFAULT {
116+
@Override
117+
public void accept(final Builder builder) {
118+
// no op, we keep default settings
119+
}
120+
},
121+
122+
/**
123+
* A preset that disables all interactive features, such as hover and click events.
124+
*
125+
* <p>This preset also removes hover and click events from the returned component
126+
* using a custom post-processor.</p>
127+
*
128+
* @since 5.0.0
129+
*/
130+
NON_INTERACTABLE {
131+
@Override
132+
public void accept(final Builder builder) {
133+
Objects.requireNonNull(builder, "builder");
134+
builder.tags(StandardTags.forPreset(this));
135+
builder.postProcessor(component ->
136+
component
137+
.toBuilder()
138+
.applyDeep(child -> child.clickEvent(null).hoverEvent(null).insertion(null))
139+
.build()
140+
.compact()
141+
);
142+
}
143+
},
144+
145+
/**
146+
* A preset that disables all non-text component types and only allows formatting
147+
* text components (i.e., color, shadow, decorations, fonts).
148+
*
149+
* <p>This preset also performs the following modifications to the resulting component
150+
* using a custom post-processor:</p>
151+
* <ul>
152+
* <li>Removes click events, hover events, and insertions from all text components.</li>
153+
* <li>Filters out any non-text components.</li>
154+
* </ul>
155+
*
156+
* @since 5.0.0
157+
*/
158+
FORMATTED_TEXT {
159+
@Override
160+
public void accept(final Builder builder) {
161+
Objects.requireNonNull(builder, "builder");
162+
builder.tags(StandardTags.forPreset(this));
163+
builder.postProcessor(component ->
164+
Component
165+
.textOfChildren(component) // Wrap it in the empty component so we don't care about the root when mapping.
166+
.toBuilder()
167+
.mapChildrenDeep(child -> {
168+
if (child instanceof TextComponent && !(child instanceof VirtualComponent)) {
169+
return child.toBuilder().clickEvent(null).hoverEvent(null).insertion(null).build();
170+
} else {
171+
return Component.empty();
172+
}
173+
})
174+
.build()
175+
.compact()
176+
);
177+
}
178+
};
179+
180+
/**
181+
* The name map.
182+
*
183+
* <p>This can be used to easily make configurable presets.</p>
184+
*
185+
* @since 5.0.0
186+
*/
187+
public static final Index<String, Preset> NAMES = Index.create(Preset.class, Preset::name);
188+
}
189+
57190
/**
58191
* Escapes all known tags in the input message, so that they are ignored in deserialization.
59192
*
@@ -302,6 +435,20 @@ static Builder builder() {
302435
return new MiniMessageImpl.BuilderImpl();
303436
}
304437

438+
/**
439+
* Creates a new {@link MiniMessage.Builder} pre-configured for a specific preset.
440+
*
441+
* @param preset the preset
442+
* @return the builder
443+
* @since 5.0.0
444+
*/
445+
static Builder builder(final Preset preset) {
446+
Objects.requireNonNull(preset, "preset");
447+
final Builder builder = MiniMessage.builder();
448+
preset.accept(builder);
449+
return builder;
450+
}
451+
305452
/**
306453
* A builder for {@link MiniMessage}.
307454
*

text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/MiniMessageImpl.java

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,12 @@
2323
*/
2424
package net.kyori.adventure.text.minimessage;
2525

26+
import java.util.Map;
2627
import java.util.Optional;
2728
import java.util.function.Consumer;
29+
import java.util.function.Function;
2830
import java.util.function.UnaryOperator;
31+
import java.util.stream.Collectors;
2932
import net.kyori.adventure.pointer.Pointered;
3033
import net.kyori.adventure.text.Component;
3134
import net.kyori.adventure.text.minimessage.internal.serializer.SerializableResolver;
@@ -54,8 +57,25 @@ record MiniMessageImpl(
5457
// We cannot store these fields in MiniMessageImpl directly due to class initialisation issues.
5558
static final class Instances {
5659
static final MiniMessage INSTANCE = SERVICE
57-
.map(Provider::miniMessage)
60+
.map(MiniMessage.Provider::miniMessage)
5861
.orElseGet(() -> new MiniMessageImpl(TagResolver.standard(), false, true, null, DEFAULT_NO_OP, DEFAULT_COMPACTING_METHOD));
62+
63+
static final Map<MiniMessage.Preset, MiniMessage> PRESETS = MiniMessage.Preset
64+
.NAMES
65+
.values()
66+
.stream()
67+
.collect(
68+
Collectors.toMap(
69+
Function.identity(),
70+
preset -> {
71+
if (preset == Preset.DEFAULT) {
72+
return INSTANCE;
73+
} else {
74+
return MiniMessage.builder(preset).build();
75+
}
76+
}
77+
)
78+
);
5979
}
6080

6181
static final UnaryOperator<String> DEFAULT_NO_OP = UnaryOperator.identity();

text-minimessage/src/main/java/net/kyori/adventure/text/minimessage/tag/standard/StandardTags.java

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
import net.kyori.adventure.text.format.NamedTextColor;
2727
import net.kyori.adventure.text.format.TextDecoration;
28+
import net.kyori.adventure.text.minimessage.MiniMessage;
2829
import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver;
2930

3031
import static java.util.Objects.requireNonNull;
@@ -67,6 +68,42 @@ private StandardTags() {
6768
)
6869
.build();
6970

71+
private static final TagResolver NON_INTERACTABLE = TagResolver.builder()
72+
.resolvers(
73+
ColorTagResolver.INSTANCE,
74+
KeybindTag.RESOLVER,
75+
TranslatableTag.RESOLVER,
76+
TranslatableFallbackTag.RESOLVER,
77+
FontTag.RESOLVER,
78+
DecorationTag.RESOLVER,
79+
GradientTag.RESOLVER,
80+
RainbowTag.RESOLVER,
81+
ResetTag.RESOLVER,
82+
NewlineTag.RESOLVER,
83+
TransitionTag.RESOLVER,
84+
SelectorTag.RESOLVER,
85+
ScoreTag.RESOLVER,
86+
NbtTag.RESOLVER,
87+
PrideTag.RESOLVER,
88+
ShadowColorTag.RESOLVER,
89+
SpriteTag.RESOLVER,
90+
SequentialHeadTag.RESOLVER
91+
)
92+
.build();
93+
94+
private static final TagResolver FORMATTED_TEXT = TagResolver.builder()
95+
.resolvers(
96+
ColorTagResolver.INSTANCE,
97+
FontTag.RESOLVER,
98+
DecorationTag.RESOLVER,
99+
GradientTag.RESOLVER,
100+
RainbowTag.RESOLVER,
101+
NewlineTag.RESOLVER,
102+
TransitionTag.RESOLVER,
103+
ShadowColorTag.RESOLVER
104+
)
105+
.build();
106+
70107
/**
71108
* Get a resolver for a specific text decoration.
72109
*
@@ -323,4 +360,19 @@ public static TagResolver sprite() {
323360
public static TagResolver defaults() {
324361
return ALL;
325362
}
363+
364+
/**
365+
* Get a resolver that handles all standard tags for a given preset.
366+
*
367+
* @param preset the preset
368+
* @return the resolver for built-in tags for a preset
369+
* @since 5.0.0
370+
*/
371+
public static TagResolver forPreset(final MiniMessage.Preset preset) {
372+
return switch (requireNonNull(preset, "preset")) {
373+
case DEFAULT -> ALL;
374+
case NON_INTERACTABLE -> NON_INTERACTABLE;
375+
case FORMATTED_TEXT -> FORMATTED_TEXT;
376+
};
377+
}
326378
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package net.kyori.adventure.text.minimessage;
2+
3+
import net.kyori.adventure.text.Component;
4+
import net.kyori.adventure.text.event.ClickEvent;
5+
import net.kyori.adventure.text.format.NamedTextColor;
6+
import net.kyori.adventure.text.format.TextDecoration;
7+
import net.kyori.adventure.text.minimessage.tag.Tag;
8+
import org.junit.jupiter.api.Test;
9+
10+
import static net.kyori.adventure.text.Component.empty;
11+
import static net.kyori.adventure.text.Component.text;
12+
import static org.junit.jupiter.api.Assertions.assertNotNull;
13+
14+
public class MiniMessagePresetTest extends AbstractTest {
15+
@Test
16+
void testAllPresetsAreConfigured() {
17+
for (final MiniMessage.Preset preset : MiniMessage.Preset.values()) {
18+
final MiniMessage miniMessage = MiniMessage.miniMessage(preset);
19+
assertNotNull(miniMessage);
20+
}
21+
}
22+
23+
@Test
24+
void testDefault() {
25+
final MiniMessage mm = MiniMessage.miniMessage(MiniMessage.Preset.DEFAULT);
26+
this.assertParsedEquals(mm,
27+
text().append(text("Hello ", NamedTextColor.RED)
28+
.append(text("world", NamedTextColor.BLUE).decorate(TextDecoration.BOLD)))
29+
.append(text("!", NamedTextColor.GREEN).clickEvent(ClickEvent.runCommand("/test")))
30+
.build(),
31+
"<red>Hello <blue><bold>world</bold></blue></red><green><click:run_command:/test>!</click></green>"
32+
);
33+
}
34+
35+
@Test
36+
void testNonInteractable() {
37+
// Click, hover, insertion tags should be ignored and thus remain as literal text.
38+
final MiniMessage mm = MiniMessage.miniMessage(MiniMessage.Preset.NON_INTERACTABLE);
39+
this.assertParsedEquals(mm,
40+
text("<click:run_command:/test>No click</click><hover:show_text:hover>No hover</hover><insert:insert>No insertion</insert>"),
41+
"<click:run_command:/test>No click</click><hover:show_text:hover>No hover</hover><insert:insert>No insertion</insert>"
42+
);
43+
44+
// Custom tags producing interactive components should be filtered.
45+
final MiniMessage mmCustom = MiniMessage.builder(MiniMessage.Preset.NON_INTERACTABLE)
46+
.editTags(t -> t.tag("dangerous", Tag.styling(ClickEvent.runCommand("/kill"))))
47+
.build();
48+
this.assertParsedEquals(mmCustom,
49+
text("test"),
50+
"<dangerous>test</dangerous>"
51+
);
52+
}
53+
54+
@Test
55+
void testFormattedText() {
56+
final MiniMessage mm = MiniMessage.miniMessage(MiniMessage.Preset.FORMATTED_TEXT);
57+
58+
// Basic formatting should work fine.
59+
this.assertParsedEquals(mm,
60+
text().append(text("Hello ", NamedTextColor.RED)
61+
.append(text("world", NamedTextColor.BLUE).decorate(TextDecoration.BOLD)))
62+
.build(),
63+
"<red>Hello <blue><bold>world</bold></blue></red>"
64+
);
65+
66+
// Click/hover should remain as literal text.
67+
this.assertParsedEquals(mm,
68+
text("<click:run_command:/test>No click</click>"),
69+
"<click:run_command:/test>No click</click>"
70+
);
71+
72+
// Non-text components (like keybind) are also not here and should remain literal.
73+
this.assertParsedEquals(mm,
74+
text("<keybind:key.jump>"),
75+
"<keybind:key.jump>"
76+
);
77+
78+
// Custom tags producing non-text components should be filtered.
79+
final MiniMessage mmCustom = MiniMessage.builder(MiniMessage.Preset.FORMATTED_TEXT)
80+
.editTags(t -> t.tag("mykey", Tag.inserting(Component.keybind("key.jump"))))
81+
.build();
82+
this.assertParsedEquals(mmCustom,
83+
empty(),
84+
"<mykey>"
85+
);
86+
87+
// Custom tags producing interactive text should also be filtered.
88+
final MiniMessage mmCustom2 = MiniMessage.builder(MiniMessage.Preset.FORMATTED_TEXT)
89+
.editTags(t -> t.tag("dangertext", Tag.inserting(text("danger").clickEvent(ClickEvent.runCommand("/kill")))))
90+
.build();
91+
this.assertParsedEquals(mmCustom2,
92+
text("danger"),
93+
"<dangertext>"
94+
);
95+
96+
// Custom tags producing non-text components in children should also be filtered.
97+
final MiniMessage mmCustom3 = MiniMessage.builder(MiniMessage.Preset.FORMATTED_TEXT)
98+
.editTags(t -> t.tag("nontextchild", Tag.inserting(text("parent").append(Component.keybind("key.jump")))))
99+
.build();
100+
this.assertParsedEquals(mmCustom3,
101+
text("parent"),
102+
"<nontextchild>"
103+
);
104+
}
105+
}

0 commit comments

Comments
 (0)