diff --git a/patches/net/minecraft/client/resources/language/ClientLanguage.java.patch b/patches/net/minecraft/client/resources/language/ClientLanguage.java.patch index 86f9a1dabe..ad10135975 100644 --- a/patches/net/minecraft/client/resources/language/ClientLanguage.java.patch +++ b/patches/net/minecraft/client/resources/language/ClientLanguage.java.patch @@ -1,26 +1,6 @@ --- a/net/minecraft/client/resources/language/ClientLanguage.java +++ b/net/minecraft/client/resources/language/ClientLanguage.java -@@ -22,36 +_,50 @@ - public class ClientLanguage extends Language { - private static final Logger LOGGER = LogUtils.getLogger(); - private final Map storage; -+ private final Map componentStorage; - private final boolean defaultRightToLeft; - -+ @Deprecated - private ClientLanguage(Map p_118914_, boolean p_118915_) { -+ this(p_118914_, p_118915_, Map.of()); -+ } -+ -+ private ClientLanguage(Map p_118914_, boolean p_118915_, Map componentStorage) { - this.storage = p_118914_; - this.defaultRightToLeft = p_118915_; -+ this.componentStorage = componentStorage; - } - - public static ClientLanguage loadFrom(ResourceManager p_265765_, List p_265743_, boolean p_265470_) { - Map map = Maps.newHashMap(); -+ Map componentMap = Maps.newHashMap(); +@@ -34,6 +_,7 @@ for (String s : p_265743_) { String s1 = String.format(Locale.ROOT, "lang/%s.json", s); @@ -28,33 +8,20 @@ for (String s2 : p_265765_.getNamespaces()) { try { - ResourceLocation resourcelocation = ResourceLocation.fromNamespaceAndPath(s2, s1); -- appendFrom(s, p_265765_.getResourceStack(resourcelocation), map); -+ appendFrom(s, p_265765_.getResourceStack(resourcelocation), map, componentMap); - } catch (Exception exception) { - LOGGER.warn("Skipped language file: {}:{} ({})", s2, s1, exception.toString()); - } - } - } - -- return new ClientLanguage(ImmutableMap.copyOf(map), p_265470_); -+ return new ClientLanguage(ImmutableMap.copyOf(map), p_265470_, ImmutableMap.copyOf(componentMap)); - } +@@ -60,6 +_,12 @@ -+ @Deprecated - private static void appendFrom(String p_235036_, List p_235037_, Map p_235038_) { -+ appendFrom(p_235036_, p_235037_, p_235038_, new java.util.HashMap<>()); + @Override + public String getOrDefault(String p_118920_, String p_265273_) { ++ return net.neoforged.neoforge.common.text.TemplateParser.stripTemplate(p_118920_, getOrDefaultRaw(p_118920_, p_265273_)); + } + -+ private static void appendFrom(String p_235036_, List p_235037_, Map p_235038_, Map componentMap) { - for (Resource resource : p_235037_) { - try (InputStream inputstream = resource.open()) { -- Language.loadFromJson(inputstream, p_235038_::put); -+ Language.loadFromJson(inputstream, p_235038_::put, componentMap::put); - } catch (IOException ioexception) { - LOGGER.warn("Failed to load translations for {} from pack {}", p_235036_, resource.sourcePackId(), ioexception); - } -@@ -76,5 +_,15 @@ ++ @Override ++ @org.jetbrains.annotations.ApiStatus.Internal ++ public String getOrDefaultRaw(String p_118920_, String p_265273_) { + return this.storage.getOrDefault(p_118920_, p_265273_); + } + +@@ -76,5 +_,10 @@ @Override public FormattedCharSequence getVisualOrder(FormattedText p_118925_) { return FormattedBidiReorder.reorder(p_118925_, this.defaultRightToLeft); @@ -63,10 +30,5 @@ + @Override + public Map getLanguageData() { + return storage; -+ } -+ -+ @Override -+ public @org.jetbrains.annotations.Nullable net.minecraft.network.chat.Component getComponent(String key) { -+ return componentStorage.get(key); } } diff --git a/patches/net/minecraft/locale/Language.java.patch b/patches/net/minecraft/locale/Language.java.patch index 9f3cb451af..4014f48bda 100644 --- a/patches/net/minecraft/locale/Language.java.patch +++ b/patches/net/minecraft/locale/Language.java.patch @@ -1,19 +1,25 @@ --- a/net/minecraft/locale/Language.java +++ b/net/minecraft/locale/Language.java -@@ -36,8 +_,10 @@ - private static Language loadDefault() { +@@ -37,10 +_,17 @@ Builder builder = ImmutableMap.builder(); BiConsumer biconsumer = builder::put; -- parseTranslations(biconsumer, "/assets/minecraft/lang/en_us.json"); + parseTranslations(biconsumer, "/assets/minecraft/lang/en_us.json"); - final Map map = builder.build(); -+ Map componentMap = new java.util.HashMap<>(); -+ parseTranslations(biconsumer, componentMap::put, "/assets/minecraft/lang/en_us.json"); + final Map map = new java.util.HashMap<>(builder.build()); -+ net.neoforged.neoforge.server.LanguageHook.captureLanguageMap(map, componentMap); ++ net.neoforged.neoforge.server.LanguageHook.captureLanguageMap(map); return new Language() { @Override public String getOrDefault(String p_128127_, String p_265421_) { -@@ -64,21 +_,51 @@ ++ return net.neoforged.neoforge.common.text.TemplateParser.stripTemplate(p_128127_, getOrDefaultRaw(p_128127_, p_265421_)); ++ } ++ ++ @Override ++ @org.jetbrains.annotations.ApiStatus.Internal ++ public String getOrDefaultRaw(String p_128127_, String p_265421_) { + return map.getOrDefault(p_128127_, p_265421_); + } + +@@ -64,6 +_,11 @@ ) .isPresent(); } @@ -21,62 +27,59 @@ + @Override + public Map getLanguageData() { + return map; -+ } -+ -+ @Override -+ public @org.jetbrains.annotations.Nullable net.minecraft.network.chat.Component getComponent(String key) { -+ return componentMap.get(key); + } }; } -+ @Deprecated - private static void parseTranslations(BiConsumer p_282031_, String p_283638_) { -+ parseTranslations(p_282031_, (key, value) -> {}, p_283638_); -+ } -+ -+ private static void parseTranslations(BiConsumer p_282031_, BiConsumer componentConsumer, String p_283638_) { - try (InputStream inputstream = Language.class.getResourceAsStream(p_283638_)) { -- loadFromJson(inputstream, p_282031_); -+ loadFromJson(inputstream, p_282031_, componentConsumer); - } catch (JsonParseException | IOException ioexception) { - LOGGER.error("Couldn't read strings from {}", p_283638_, ioexception); - } - } - - public static void loadFromJson(InputStream p_128109_, BiConsumer p_128110_) { -+ loadFromJson(p_128109_, p_128110_, (key, value) -> {}); -+ } -+ -+ public static void loadFromJson(InputStream p_128109_, BiConsumer p_128110_, BiConsumer componentConsumer) { +@@ -79,6 +_,11 @@ JsonObject jsonobject = GSON.fromJson(new InputStreamReader(p_128109_, StandardCharsets.UTF_8), JsonObject.class); for (Entry entry : jsonobject.entrySet()) { + if (entry.getValue().isJsonArray()) { -+ var component = net.minecraft.network.chat.ComponentSerialization.CODEC -+ .parse(com.mojang.serialization.JsonOps.INSTANCE, entry.getValue()) -+ .getOrThrow(msg -> new com.google.gson.JsonParseException("Error parsing translation for " + entry.getKey() + ": " + msg)); -+ -+ p_128110_.accept(entry.getKey(), component.getString()); -+ componentConsumer.accept(entry.getKey(), component); -+ ++ p_128110_.accept(entry.getKey(), net.neoforged.neoforge.common.text.JsonTemplateParser.reencodeJson(entry.getValue())); + continue; + } + String s = UNSUPPORTED_FORMAT_PATTERN.matcher(GsonHelper.convertToString(entry.getValue(), entry.getKey())).replaceAll("%$1s"); p_128110_.accept(entry.getKey(), s); } -@@ -90,6 +_,13 @@ - - public static void inject(Language p_128115_) { +@@ -92,12 +_,21 @@ instance = p_128115_; -+ } -+ + } + + // Neo: All helpers methods below are injected by Neo to ease modder's usage of Language + public Map getLanguageData() { return ImmutableMap.of(); } + + public String getOrDefault(String p_128111_) { + return this.getOrDefault(p_128111_, p_128111_); + } + + public abstract String getOrDefault(String p_265702_, String p_265599_); + ++ // Neo: Bypass for the formatter ++ @org.jetbrains.annotations.ApiStatus.Internal ++ public String getOrDefaultRaw(String p_265702_, String p_265599_) { ++ return getOrDefault(p_265702_, p_265599_); ++ } ++ + public abstract boolean has(String p_128117_); + + public abstract boolean isDefaultRightToLeft(); +@@ -106,5 +_,17 @@ + + public List getVisualOrder(List p_128113_) { + return p_128113_.stream().map(this::getVisualOrder).collect(ImmutableList.toImmutableList()); ++ } ++ ++ // Neo: For API stability in 1.21(.1) only, does nothing ++ @Deprecated(forRemoval = true) + public @org.jetbrains.annotations.Nullable net.minecraft.network.chat.Component getComponent(String key) { + return null; ++ } ++ ++ // Neo: For API stability in 1.21(.1) only, extra parameter does nothing ++ @Deprecated(forRemoval = true) ++ public static void loadFromJson(InputStream p_128109_, BiConsumer p_128110_, BiConsumer componentConsumer) { ++ loadFromJson(p_128109_, p_128110_); } - - public String getOrDefault(String p_128111_) { + } diff --git a/patches/net/minecraft/network/chat/contents/TranslatableContents.java.patch b/patches/net/minecraft/network/chat/contents/TranslatableContents.java.patch index 8e9f97f07e..2e6d060e9c 100644 --- a/patches/net/minecraft/network/chat/contents/TranslatableContents.java.patch +++ b/patches/net/minecraft/network/chat/contents/TranslatableContents.java.patch @@ -14,62 +14,17 @@ } @Override -@@ -92,6 +_,13 @@ +@@ -92,10 +_,12 @@ Language language = Language.getInstance(); if (language != this.decomposedWith) { this.decomposedWith = language; +- String s = this.fallback != null ? language.getOrDefault(this.key, this.fallback) : language.getOrDefault(this.key); + -+ Component langComponent = language.getComponent(this.key); -+ if (langComponent != null) { -+ this.decomposedParts = ImmutableList.of(langComponent); -+ return; -+ } -+ - String s = this.fallback != null ? language.getOrDefault(this.key, this.fallback) : language.getOrDefault(this.key); ++ String s = this.fallback != null ? language.getOrDefaultRaw(this.key, this.fallback) : language.getOrDefaultRaw(this.key, this.key); try { -@@ -170,6 +_,12 @@ - public Optional visit(FormattedText.StyledContentConsumer p_237521_, Style p_237522_) { - this.decompose(); - -+ if (!net.neoforged.neoforge.common.util.InsertingContents.pushTranslation(this)) { -+ // Reference cycle. -+ return Optional.empty(); -+ } -+ -+ try { - for (FormattedText formattedtext : this.decomposedParts) { - Optional optional = formattedtext.visit(p_237521_, p_237522_); - if (optional.isPresent()) { -@@ -178,12 +_,21 @@ - } - - return Optional.empty(); -+ } finally { -+ net.neoforged.neoforge.common.util.InsertingContents.popTranslation(); -+ } - } - - @Override - public Optional visit(FormattedText.ContentConsumer p_237519_) { - this.decompose(); - -+ if (!net.neoforged.neoforge.common.util.InsertingContents.pushTranslation(this)) { -+ // Reference cycle. -+ return Optional.empty(); -+ } -+ -+ try { - for (FormattedText formattedtext : this.decomposedParts) { - Optional optional = formattedtext.visit(p_237519_); - if (optional.isPresent()) { -@@ -192,6 +_,9 @@ - } - - return Optional.empty(); -+ } finally { -+ net.neoforged.neoforge.common.util.InsertingContents.popTranslation(); -+ } - } - - @Override + Builder builder = ImmutableList.builder(); ++ s = net.neoforged.neoforge.common.text.TemplateParser.decomposeTemplate(this, this.args, s, builder::add); + this.decomposeTemplate(s, builder::add); + this.decomposedParts = builder.build(); + } catch (TranslatableFormatException translatableformatexception) { diff --git a/src/main/java/net/neoforged/neoforge/common/text/JsonTemplateParser.java b/src/main/java/net/neoforged/neoforge/common/text/JsonTemplateParser.java new file mode 100644 index 0000000000..8a4bc0833f --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/common/text/JsonTemplateParser.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.common.text; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import com.mojang.serialization.JsonOps; +import java.util.function.Consumer; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.ComponentSerialization; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.MutableComponent; +import net.neoforged.neoforge.common.util.InsertingContents; +import org.jetbrains.annotations.ApiStatus; + +@ApiStatus.Internal +public class JsonTemplateParser { + protected static final Gson GSON = new Gson(); + + public static String handle(final String template, final Object[] args, final Consumer consumer) { + try { + process(parse(GSON.fromJson(template, JsonArray.class)), args, consumer); + return ""; + } catch (JsonParseException e) { + throw new TemplateParser.ParsingException(e.getMessage()); + } + } + + private static void process(final Component component, final Object[] args, final Consumer consumer) { + if (component.getContents() instanceof InsertingContents icon) { + consumer.accept(getArgument(args, icon.index()).withStyle(component.getStyle())); + } else if (!component.getSiblings().isEmpty()) { + consumer.accept(MutableComponent.create(component.getContents()).withStyle(component.getStyle())); + } else { + consumer.accept(component); + } + for (final Component sibling : component.getSiblings()) { + process(sibling, args, consumer); + } + } + + public static String handle(final String template) { + try { + return parse(GSON.fromJson(template, JsonArray.class)).getString(); + } catch (JsonParseException e) { + throw new TemplateParser.ParsingException(e.getMessage()); + } + } + + private static Component parse(JsonElement element) { + return ComponentSerialization.CODEC + .parse(JsonOps.INSTANCE, element) + .getOrThrow(msg -> new JsonParseException("Error parsing json: " + msg)); + } + + private static MutableComponent getArgument(final Object[] args, final int index) { + if (index >= 0 && index < args.length) { + final Object object = args[index]; + if (object instanceof MutableComponent) { + return (MutableComponent) object; + } else if (object instanceof Component) { + return Component.empty().append((Component) object); + } else if (object == null) { + return Component.literal("null"); + } else { + return Component.literal(object.toString()); + } + } else { + throw new TemplateParser.ParsingException("Invalid index: " + index); + } + } + + public static String reencodeJson(JsonElement element) { + return TemplateParser.JSON_MARKER + GSON.toJson(element); + } +} diff --git a/src/main/java/net/neoforged/neoforge/common/text/TemplateParser.java b/src/main/java/net/neoforged/neoforge/common/text/TemplateParser.java new file mode 100644 index 0000000000..ff0f438c9f --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/common/text/TemplateParser.java @@ -0,0 +1,165 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +package net.neoforged.neoforge.common.text; + +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import net.minecraft.locale.Language; +import net.minecraft.network.chat.FormattedText; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.network.chat.contents.TranslatableFormatException; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.internal.versions.neoforge.NeoForgeVersion; +import org.apache.commons.lang3.function.TriFunction; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.ApiStatus; + +public final class TemplateParser { + /** + * Indicates that parsing has failed and that the raw string value shall be used instead. + */ + public static class ParsingException extends RuntimeException { + // Note: This should extend Exception, but it is thrown through lambdas which cannot have a "throws" declaration + private static final long serialVersionUID = 6142968319664791595L; + + ParsingException(String message) { + super(message); + } + } + + private static final Logger LOGGER = LogManager.getLogger(); + private static final String TEMPLATE_MARKER = "%n"; + private static final Pattern MARKER_PATTERN = Pattern.compile("^" + TEMPLATE_MARKER + "\\(([a-z0-9_.-]+:[a-z0-9_.-]+)\\) *(.*)$"); + private static final ResourceLocation SJSON = ResourceLocation.fromNamespaceAndPath(NeoForgeVersion.MOD_ID, "sjson"); + private static final ResourceLocation VANILLA = ResourceLocation.withDefaultNamespace("default"); + public static final String JSON_MARKER = TEMPLATE_MARKER + "(" + SJSON + ")"; + + private static final Map, String>, Function>> PARSERS = new HashMap<>(); + + static { + register(VANILLA, (template, args, consumer) -> template, template -> template); + register(SJSON, JsonTemplateParser::handle, JsonTemplateParser::handle); + } + + /** + * Registers custom format parsers for the given {@link ResourceLocation}. + * + * @param id The {@link ResourceLocation} as present in the %n(...) marker. + * @param decomposer A function to handle converting the raw translation text into {@link FormattedText}s. Can throw {@link ParsingException} to report errors. + * Note that this is responsible for inserting any parameters that may be there. The returned string will be handed over to vanilla parsing, so + * this function also can restrict itself to transform the translation string. + * @param stripper A function to handle converting the raw translation into format-free texts. Can throw {@link ParsingException} to report errors. + * Note that this shall convert any parameters into their vanilla form ("%1$s"...). + * @return true if the parser was registered, false otherwise. + */ + public static boolean register(ResourceLocation id, TriFunction, String> decomposer, Function stripper) { + synchronized (PARSERS) { + return PARSERS.putIfAbsent(id, Pair.of(decomposer, stripper)) == null; + } + } + + private static Pair, String>, Function> getParser(ResourceLocation key) throws ParsingException { + return PARSERS.computeIfAbsent(key, rl -> { + throw new ParsingException("Unknown format specified: " + rl); + }); + } + + @ApiStatus.Internal + protected static Pair getFormat(String template) { + if (template.startsWith(TEMPLATE_MARKER)) { + Matcher match = MARKER_PATTERN.matcher(template); + if (match.matches()) { + return Pair.of(ResourceLocation.parse(match.group(1)), match.group(2)); + } else { + return Pair.of(SJSON, template.replaceFirst(TEMPLATE_MARKER, "")); + } + } else { + return Pair.of(VANILLA, template); + } + } + + @ApiStatus.Internal + public static String decomposeTemplate(TranslatableContents translatableContents, Object[] args, String template, Consumer consumer) { + try { + Pair format = getFormat(template); + return getParser(format.getKey()).getLeft().apply(format.getValue(), args, consumer); + } catch (ParsingException e) { + LOGGER.error("Error parsing language string for key {} with value '{}': {}", translatableContents.getKey(), template, e.getMessage()); + throw new TranslatableFormatException(translatableContents, e.getMessage()); + } + } + + @ApiStatus.Internal + public static String stripTemplate(String key, String template) { + try { + Pair format = getFormat(template); + return getParser(format.getKey()).getRight().apply(format.getValue()); + } catch (ParsingException e) { + LOGGER.error("Error parsing language string for key {} with value '{}': {}", key, template, e.getMessage()); + return template; + } + } + + /** + * Tries to parse all translation values in the currently loaded language that match the given predicate and returns all found errors. + * + * @param filter Predicate on the key. + * @return A list of pairs, with the translation key in the left and the error message in the right value. + */ + public static List> test(Predicate filter) { + return Language.getInstance().getLanguageData().entrySet().stream().filter(entry -> filter.test(entry.getKey())).map( + entry -> { + try { + Pair format = getFormat(entry.getValue()); + getParser(format.getKey()).getRight().apply(format.getValue()); + return null; + } catch (ParsingException e) { + return Pair.of(entry.getKey(), e.getMessage()); + } + }).filter(p -> p != null).toList(); + } + + /** + * Tries to parse all translation values in the specified language file and returns all found errors. + * + * @param modid The namespace (mod id) of the language file. + * @param language The language (e.g. "en_us") of the language file. + * @return A list of pairs, with the translation key in the left and the error message in the right value. + */ + public static List> test(String modid, String language) { + if (!ResourceLocation.isValidNamespace(modid)) { + return List.of(Pair.of(modid, "Not a valid mod id (directory traversal attack?)")); + } + if (!ResourceLocation.isValidNamespace(language)) { + return List.of(Pair.of(language, "Not a valid language name (directory traversal attack?)")); + } + try (InputStream input = Thread.currentThread().getContextClassLoader().getResourceAsStream("assets/" + modid + "/lang/" + language + ".json")) { + assert input != null; + List> result = new ArrayList<>(); + Language.loadFromJson(input, (key, value) -> { + try { + Pair format = getFormat(value); + getParser(format.getKey()).getRight().apply(format.getValue()); + } catch (ParsingException e) { + result.add(Pair.of(key, e.getMessage())); + } + }); + return result; + } catch (Exception exception) { + return List.of(Pair.of(modid + "/" + language, "Failed to read language file: " + exception.getMessage())); + } + } +} diff --git a/src/main/java/net/neoforged/neoforge/common/text/package-info.java b/src/main/java/net/neoforged/neoforge/common/text/package-info.java new file mode 100644 index 0000000000..6aeb51d7fb --- /dev/null +++ b/src/main/java/net/neoforged/neoforge/common/text/package-info.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) NeoForged and contributors + * SPDX-License-Identifier: LGPL-2.1-only + */ + +@FieldsAreNonnullByDefault +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package net.neoforged.neoforge.common.text; + +import javax.annotation.ParametersAreNonnullByDefault; +import net.minecraft.FieldsAreNonnullByDefault; +import net.minecraft.MethodsReturnNonnullByDefault; diff --git a/src/main/java/net/neoforged/neoforge/common/util/InsertingContents.java b/src/main/java/net/neoforged/neoforge/common/util/InsertingContents.java index da4a165052..7b15fe4354 100644 --- a/src/main/java/net/neoforged/neoforge/common/util/InsertingContents.java +++ b/src/main/java/net/neoforged/neoforge/common/util/InsertingContents.java @@ -7,14 +7,10 @@ import com.mojang.serialization.MapCodec; import com.mojang.serialization.codecs.RecordCodecBuilder; -import java.util.ArrayDeque; -import java.util.Deque; import java.util.Optional; -import net.minecraft.network.chat.Component; import net.minecraft.network.chat.ComponentContents; import net.minecraft.network.chat.FormattedText; import net.minecraft.network.chat.Style; -import net.minecraft.network.chat.contents.TranslatableContents; import net.minecraft.util.ExtraCodecs; import org.jetbrains.annotations.ApiStatus; @@ -26,55 +22,14 @@ public record InsertingContents(int index) implements ComponentContents { public static final ComponentContents.Type TYPE = new ComponentContents.Type<>(CODEC, "neoforge:inserting"); - private static final ThreadLocal> TRANSLATION_STACK = ThreadLocal.withInitial(ArrayDeque::new); - - @ApiStatus.Internal - public static boolean pushTranslation(TranslatableContents contents) { - for (TranslatableContents other : TRANSLATION_STACK.get()) { - if (contents == other) { - return false; - } - } - - TRANSLATION_STACK.get().push(contents); - return true; - } - - @ApiStatus.Internal - public static void popTranslation() { - TRANSLATION_STACK.get().pop(); - } - @Override public Optional visit(FormattedText.ContentConsumer visitor) { - var translation = TRANSLATION_STACK.get().peek(); - - if (translation == null || translation.getArgs().length <= index) - return visitor.accept("%" + (index + 1) + "$s"); - - Object arg = translation.getArgs()[index]; - - if (arg instanceof Component component) { - return component.visit(visitor); - } else { - return visitor.accept(arg.toString()); - } + return visitor.accept("%" + (index + 1) + "$s"); } @Override public Optional visit(FormattedText.StyledContentConsumer visitor, Style style) { - var translation = TRANSLATION_STACK.get().peek(); - - if (translation == null || translation.getArgs().length <= index) - return visitor.accept(style, "%" + (index + 1) + "$s"); - - Object arg = translation.getArgs()[index]; - - if (arg instanceof Component component) { - return component.visit(visitor, style); - } else { - return visitor.accept(style, arg.toString()); - } + return visitor.accept(style, "%" + (index + 1) + "$s"); } @Override diff --git a/src/main/java/net/neoforged/neoforge/server/LanguageHook.java b/src/main/java/net/neoforged/neoforge/server/LanguageHook.java index 7e0f2e0b6e..92d657ed6e 100644 --- a/src/main/java/net/neoforged/neoforge/server/LanguageHook.java +++ b/src/main/java/net/neoforged/neoforge/server/LanguageHook.java @@ -10,7 +10,6 @@ import java.util.Locale; import java.util.Map; import net.minecraft.locale.Language; -import net.minecraft.network.chat.Component; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MinecraftServer; import net.minecraft.server.packs.PackType; @@ -30,19 +29,13 @@ public class LanguageHook { private static final Logger LOGGER = LogManager.getLogger(); private static Map defaultLanguageTable = new HashMap<>(); - private static Map defaultLanguageComponentTable = new HashMap<>(); private static Map modTable = new HashMap<>(); - private static Map modComponentTable = new HashMap<>(); - public static void captureLanguageMap(Map table, Map componentTable) { + public static void captureLanguageMap(Map table) { defaultLanguageTable = table; - defaultLanguageComponentTable = componentTable; if (!modTable.isEmpty()) { defaultLanguageTable.putAll(modTable); } - if (!modComponentTable.isEmpty()) { - defaultLanguageComponentTable.putAll(modComponentTable); - } } private static void loadLanguage(String langName, MinecraftServer server) { @@ -62,7 +55,7 @@ private static void loadLanguage(String langName, MinecraftServer server) { ResourceLocation langResource = ResourceLocation.fromNamespaceAndPath(namespace, langFile); for (Resource resource : clientResources.getResourceStack(langResource)) { try (InputStream stream = resource.open()) { - Language.loadFromJson(stream, (key, value) -> modTable.put(key, value), (key, value) -> modComponentTable.put(key, value)); + Language.loadFromJson(stream, (key, value) -> modTable.put(key, value)); } } loaded++; @@ -79,14 +72,14 @@ public static void loadBuiltinLanguages() { try (InputStream input = classLoader.getResourceAsStream("assets/minecraft/lang/en_us.json")) { assert input != null; - Language.loadFromJson(input, (key, value) -> modTable.put(key, value), (key, value) -> modComponentTable.put(key, value)); + Language.loadFromJson(input, (key, value) -> modTable.put(key, value)); } catch (Exception exception) { LOGGER.warn("Failed to load built-in language file for Minecraft", exception); } try (InputStream input = classLoader.getResourceAsStream("assets/neoforge/lang/en_us.json")) { assert input != null; - Language.loadFromJson(input, (key, value) -> modTable.put(key, value), (key, value) -> modComponentTable.put(key, value)); + Language.loadFromJson(input, (key, value) -> modTable.put(key, value)); } catch (Exception exception) { LOGGER.warn("Failed to load built-in language file for NeoForge", exception); } @@ -99,10 +92,8 @@ public static void loadBuiltinLanguages() { static void loadModLanguages(MinecraftServer server) { modTable = new HashMap<>(5000); - modComponentTable = new HashMap<>(); loadLanguage("en_us", server); defaultLanguageTable.putAll(modTable); - defaultLanguageComponentTable.putAll(modComponentTable); I18nManager.injectTranslations(modTable); } } diff --git a/tests/src/main/java/net/neoforged/neoforge/debug/resources/RichTranslationsTest.java b/tests/src/main/java/net/neoforged/neoforge/debug/resources/RichTranslationsTest.java index b624401e77..93e8bfb9f4 100644 --- a/tests/src/main/java/net/neoforged/neoforge/debug/resources/RichTranslationsTest.java +++ b/tests/src/main/java/net/neoforged/neoforge/debug/resources/RichTranslationsTest.java @@ -5,6 +5,7 @@ package net.neoforged.neoforge.debug.resources; +import java.util.Collections; import java.util.Optional; import net.minecraft.ChatFormatting; import net.minecraft.gametest.framework.GameTest; @@ -12,6 +13,8 @@ import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Style; import net.minecraft.network.chat.TextColor; +import net.minecraft.resources.ResourceLocation; +import net.neoforged.neoforge.common.text.TemplateParser; import net.neoforged.testframework.DynamicTest; import net.neoforged.testframework.annotation.ForEachTest; import net.neoforged.testframework.annotation.TestHolder; @@ -27,27 +30,29 @@ public class RichTranslationsTest { @EmptyTemplate("1x1x1") static void richTranslations(final DynamicTest test) { test.onGameTest(helper -> { - String arg = "Example argument"; - Component simple = Component.translatable("rich_translations_test.simple_translation", arg); - Component simpleRich = Component.translatable("rich_translations_test.simple_rich_translation", arg); + final String arg = "Example argument"; + final Component simple = Component.translatable("rich_translations_test.simple_translation", arg); + final Component simpleRich = Component.translatable("rich_translations_test.simple_rich_translation", arg); helper.assertTrue(simpleRich.getString().equals(simple.getString()), "Rich translation isn't equivalent to simple translation"); - String translation = Language.getInstance().getOrDefault("rich_translations_test.simple_rich_translation"); + final String translation = Language.getInstance().getOrDefault("rich_translations_test.simple_rich_translation"); helper.assertTrue( String.format(translation, arg).equals(simpleRich.getString()), "Translatable component isn't equivalent to I18n"); - Component fancy = Component.translatable("rich_translations_test.fancy_rich_translation", arg); - MutableBoolean foundRed = new MutableBoolean(); - MutableBoolean foundBlue = new MutableBoolean(); + final Component fancy = Component.translatable("rich_translations_test.fancy_rich_translation", arg); + final MutableBoolean foundRed = new MutableBoolean(); + final MutableBoolean foundBlue = new MutableBoolean(); fancy.visit((style, content) -> { - if (TextColor.fromLegacyFormat(ChatFormatting.RED).equals(style.getColor()) && content.equals("Ooo, colors!")) + if (TextColor.fromLegacyFormat(ChatFormatting.RED).equals(style.getColor()) && content.equals("Ooo, colors!")) { foundRed.setTrue(); + } - if (TextColor.fromLegacyFormat(ChatFormatting.BLUE).equals(style.getColor()) && content.equals(arg)) + if (TextColor.fromLegacyFormat(ChatFormatting.BLUE).equals(style.getColor()) && content.equals(arg)) { foundBlue.setTrue(); + } return Optional.empty(); }, Style.EMPTY); @@ -57,4 +62,80 @@ static void richTranslations(final DynamicTest test) { helper.succeed(); }); } + + static { + TemplateParser.register(ResourceLocation.parse("neotest:test"), (template, arg, consumer) -> { + consumer.accept(Component.literal(template.replace('X', 'n'))); + return ""; + }, template -> template.replace('X', 'n')); + } + + @TestHolder(description = "Tests that rich translations (custom format) work properly", enabledByDefault = true) + @GameTest + @EmptyTemplate("1x1x1") + static void richTranslations2(final DynamicTest test) { + test.onGameTest(helper -> { + final Component vanilla = Component.translatable("rich_translations_test.simple_vanilla_translation"); + final Component custom = Component.translatable("rich_translations_test.simple_custom_translation"); + + helper.assertValueEqual(custom.getString(), vanilla.getString(), "Custom Component translation"); + + final String vanilla_string = Language.getInstance().getOrDefault("rich_translations_test.simple_vanilla_translation"); + final String custom_string = Language.getInstance().getOrDefault("rich_translations_test.simple_custom_translation"); + + helper.assertValueEqual(custom_string, vanilla_string, "Custom raw translation"); + + helper.succeed(); + }); + } + + @TestHolder(description = "Tests that rich translations (text json style) work properly", enabledByDefault = true) + @GameTest + @EmptyTemplate("1x1x1") + static void richTranslations3(final DynamicTest test) { + test.onGameTest(helper -> { + final String arg = "Example argument"; + final Component simple = Component.translatable("rich_translations_test.simple_translation", arg); + final Component simpleRich = Component.translatable("rich_translations_test.simple_rich_translation3", arg); + + helper.assertTrue(simpleRich.getString().equals(simple.getString()), "Rich translation isn't equivalent to simple translation"); + + final String translation = Language.getInstance().getOrDefault("rich_translations_test.simple_rich_translation3"); + helper.assertTrue( + String.format(translation, arg).equals(simpleRich.getString()), + "Translatable component isn't equivalent to I18n"); + + final Component fancy = Component.translatable("rich_translations_test.fancy_rich_translation3", arg); + final MutableBoolean foundRed = new MutableBoolean(); + final MutableBoolean foundBlue = new MutableBoolean(); + + fancy.visit((style, content) -> { + if (TextColor.fromLegacyFormat(ChatFormatting.RED).equals(style.getColor()) && content.equals("Ooo, colors!")) { + foundRed.setTrue(); + } + + if (TextColor.fromLegacyFormat(ChatFormatting.BLUE).equals(style.getColor()) && content.equals(arg)) { + foundBlue.setTrue(); + } + + return Optional.empty(); + }, Style.EMPTY); + + helper.assertTrue(foundBlue.isTrue() && foundRed.isTrue(), "Rich translation lost colors"); + + helper.succeed(); + }); + } + + @TestHolder(description = "Tests that all rich translations in the lang file are correct", enabledByDefault = true) + @GameTest + @EmptyTemplate("1x1x1") + static void richTranslations4(final DynamicTest test) { + test.onGameTest(helper -> { + helper.assertValueEqual(TemplateParser.test(key -> true), Collections.emptyList(), "Rich translations valid"); + helper.assertValueEqual(TemplateParser.test("rich_translations_test", "en_us"), Collections.emptyList(), "Rich translations valid (file test)"); + helper.assertValueEqual(TemplateParser.test("rich_translations_test", "de_de"), Collections.emptyList(), "Rich translations valid (file test 2)"); + helper.succeed(); + }); + } } diff --git a/tests/src/main/resources/assets/rich_translations_test/lang/de_de.json b/tests/src/main/resources/assets/rich_translations_test/lang/de_de.json new file mode 100644 index 0000000000..f435c54887 --- /dev/null +++ b/tests/src/main/resources/assets/rich_translations_test/lang/de_de.json @@ -0,0 +1,8 @@ +{ + "rich_translations_test.vanilla": "Valid for vanilla", + "rich_translations_test.json": [ + "json translation." + ], + "rich_translations_test.sjson": "%n[\"sjson translation.\"]", + "rich_translations_test.custom": "%n(neotest:test) custom traXslatioX." +} \ No newline at end of file diff --git a/tests/src/main/resources/assets/rich_translations_test/lang/en_us.json b/tests/src/main/resources/assets/rich_translations_test/lang/en_us.json index 5a4bc1b356..a818757c8f 100644 --- a/tests/src/main/resources/assets/rich_translations_test/lang/en_us.json +++ b/tests/src/main/resources/assets/rich_translations_test/lang/en_us.json @@ -4,10 +4,14 @@ "Simple non-rich translation. ", {"index": 0} ], + "rich_translations_test.simple_rich_translation3": "%n[\"Simple non-rich translation. \",{\"index\": 0}]", "rich_translations_test.fancy_rich_translation": [ "", {"text": "Ooo, colors!", "color": "red"}, " ", {"index": 0, "color": "blue"} - ] + ], + "rich_translations_test.fancy_rich_translation3": "%n[\"\",{\"text\": \"Ooo, colors!\", \"color\": \"red\"},\" \",{\"index\": 0, \"color\": \"blue\"}]", + "rich_translations_test.simple_vanilla_translation": "Simple custom translation.", + "rich_translations_test.simple_custom_translation": "%n(neotest:test)Simple custom traXslatioX." } \ No newline at end of file