diff --git a/plugin/src/main/java/com/lishid/openinv/OpenInv.java b/plugin/src/main/java/com/lishid/openinv/OpenInv.java index cfca819..25b28aa 100644 --- a/plugin/src/main/java/com/lishid/openinv/OpenInv.java +++ b/plugin/src/main/java/com/lishid/openinv/OpenInv.java @@ -28,9 +28,10 @@ import com.lishid.openinv.internal.ISpecialEnderChest; import com.lishid.openinv.internal.ISpecialInventory; import com.lishid.openinv.internal.ISpecialPlayerInventory; import com.lishid.openinv.util.ConfigUpdater; -import com.lishid.openinv.util.LanguageManager; import com.lishid.openinv.util.Permissions; import com.lishid.openinv.util.StringMetric; +import com.lishid.openinv.util.lang.LanguageManager; +import com.lishid.openinv.util.lang.Replacement; import java.util.ArrayList; import java.util.Iterator; import java.util.Map; @@ -463,7 +464,7 @@ public class OpenInv extends JavaPlugin implements IOpenInv { public @Nullable String getLocalizedMessage( @NotNull CommandSender sender, @NotNull String key, - String @NotNull ... replacements) { + Replacement @NotNull ... replacements) { return this.languageManager.getValue(key, getLocale(sender), replacements); } @@ -483,7 +484,7 @@ public class OpenInv extends JavaPlugin implements IOpenInv { } } - public void sendMessage(@NotNull CommandSender sender, @NotNull String key, String @NotNull... replacements) { + public void sendMessage(@NotNull CommandSender sender, @NotNull String key, Replacement @NotNull... replacements) { String message = getLocalizedMessage(sender, key, replacements); if (message != null && !message.isEmpty()) { diff --git a/plugin/src/main/java/com/lishid/openinv/commands/ContainerSettingCommand.java b/plugin/src/main/java/com/lishid/openinv/commands/ContainerSettingCommand.java index 406b472..c8dd3c7 100644 --- a/plugin/src/main/java/com/lishid/openinv/commands/ContainerSettingCommand.java +++ b/plugin/src/main/java/com/lishid/openinv/commands/ContainerSettingCommand.java @@ -18,6 +18,7 @@ package com.lishid.openinv.commands; import com.lishid.openinv.OpenInv; import com.lishid.openinv.util.TabCompleter; +import com.lishid.openinv.util.lang.Replacement; import java.util.Collections; import java.util.List; import java.util.function.BiConsumer; @@ -69,7 +70,11 @@ public class ContainerSettingCommand implements TabExecutor { onOff = String.valueOf(getSetting.test(player)); } - plugin.sendMessage(sender, "messages.info.settingState","%setting%", any ? "AnyContainer" : "SilentContainer", "%state%", onOff); + plugin.sendMessage( + sender, + "messages.info.settingState", + new Replacement("%setting%", any ? "AnyContainer" : "SilentContainer"), + new Replacement("%state%", onOff)); return true; } diff --git a/plugin/src/main/java/com/lishid/openinv/commands/OpenInvCommand.java b/plugin/src/main/java/com/lishid/openinv/commands/OpenInvCommand.java index 8117697..d4f193a 100644 --- a/plugin/src/main/java/com/lishid/openinv/commands/OpenInvCommand.java +++ b/plugin/src/main/java/com/lishid/openinv/commands/OpenInvCommand.java @@ -20,6 +20,7 @@ import com.lishid.openinv.OpenInv; import com.lishid.openinv.internal.ISpecialInventory; import com.lishid.openinv.util.Permissions; import com.lishid.openinv.util.TabCompleter; +import com.lishid.openinv.util.lang.Replacement; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -175,16 +176,20 @@ public class OpenInvCommand implements TabExecutor { // Protected check if (!Permissions.OVERRIDE.hasPermission(player) && Permissions.EXEMPT.hasPermission(onlineTarget)) { - plugin.sendMessage(player, "messages.error.permissionExempt", - "%target%", onlineTarget.getDisplayName()); + plugin.sendMessage( + player, + "messages.error.permissionExempt", + new Replacement("%target%", onlineTarget.getDisplayName())); return; } // Crossworld check if (!Permissions.CROSSWORLD.hasPermission(player) && !onlineTarget.getWorld().equals(player.getWorld())) { - plugin.sendMessage(player, "messages.error.permissionCrossWorld", - "%target%", onlineTarget.getDisplayName()); + plugin.sendMessage( + player, + "messages.error.permissionCrossWorld", + new Replacement("%target%", onlineTarget.getDisplayName())); return; } } diff --git a/plugin/src/main/java/com/lishid/openinv/commands/SearchContainerCommand.java b/plugin/src/main/java/com/lishid/openinv/commands/SearchContainerCommand.java index 37e7c53..bef78bb 100644 --- a/plugin/src/main/java/com/lishid/openinv/commands/SearchContainerCommand.java +++ b/plugin/src/main/java/com/lishid/openinv/commands/SearchContainerCommand.java @@ -18,6 +18,7 @@ package com.lishid.openinv.commands; import com.lishid.openinv.OpenInv; import com.lishid.openinv.util.TabCompleter; +import com.lishid.openinv.util.lang.Replacement; import java.util.Collections; import java.util.List; import org.bukkit.Chunk; @@ -57,7 +58,10 @@ public class SearchContainerCommand implements TabExecutor { Material material = Material.getMaterial(args[0].toUpperCase()); if (material == null) { - plugin.sendMessage(sender, "messages.error.invalidMaterial", "%target%", args[0]); + plugin.sendMessage( + sender, + "messages.error.invalidMaterial", + new Replacement("%target%", args[0])); return false; } @@ -100,13 +104,18 @@ public class SearchContainerCommand implements TabExecutor { if (locations.length() > 0) { locations.delete(locations.length() - 2, locations.length()); } else { - plugin.sendMessage(sender, "messages.info.container.noMatches", - "%target%", material.name()); + plugin.sendMessage( + sender, + "messages.info.container.noMatches", + new Replacement("%target%", material.name())); return true; } - plugin.sendMessage(sender, "messages.info.container.matches", - "%target%", material.name(), "%detail%", locations.toString()); + plugin.sendMessage( + sender, + "messages.info.container.matches", + new Replacement("%target%", material.name()), + new Replacement("%detail%", locations.toString())); return true; } diff --git a/plugin/src/main/java/com/lishid/openinv/commands/SearchEnchantCommand.java b/plugin/src/main/java/com/lishid/openinv/commands/SearchEnchantCommand.java index c79aa43..2c52f5d 100644 --- a/plugin/src/main/java/com/lishid/openinv/commands/SearchEnchantCommand.java +++ b/plugin/src/main/java/com/lishid/openinv/commands/SearchEnchantCommand.java @@ -18,6 +18,7 @@ package com.lishid.openinv.commands; import com.lishid.openinv.OpenInv; import com.lishid.openinv.util.TabCompleter; +import com.lishid.openinv.util.lang.Replacement; import java.util.Collections; import java.util.List; import org.bukkit.Material; @@ -114,14 +115,18 @@ public class SearchEnchantCommand implements TabExecutor { // Matches found, delete trailing comma and space players.delete(players.length() - 2, players.length()); } else { - plugin.sendMessage(sender, "messages.info.player.noMatches", - "%target%", (enchant != null ? enchant.getKey().toString() : "") + " >= " + level); + plugin.sendMessage( + sender, + "messages.info.player.noMatches", + new Replacement("%target%", (enchant != null ? enchant.getKey().toString() : "") + " >= " + level)); return true; } - plugin.sendMessage(sender, "messages.info.player.matches", - "%target%", (enchant != null ? enchant.getKey().toString() : "") + " >= " + level, - "%detail%", players.toString()); + plugin.sendMessage( + sender, + "messages.info.player.matches", + new Replacement("%target%", (enchant != null ? enchant.getKey().toString() : "") + " >= " + level), + new Replacement("%detail%", players.toString())); return true; } diff --git a/plugin/src/main/java/com/lishid/openinv/commands/SearchInvCommand.java b/plugin/src/main/java/com/lishid/openinv/commands/SearchInvCommand.java index 3b2cbfb..60235bd 100644 --- a/plugin/src/main/java/com/lishid/openinv/commands/SearchInvCommand.java +++ b/plugin/src/main/java/com/lishid/openinv/commands/SearchInvCommand.java @@ -1,5 +1,5 @@ /* - * Copyright (C) 2011-2021 lishid. All rights reserved. + * Copyright (C) 2011-2022 lishid. All rights reserved. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -18,6 +18,7 @@ package com.lishid.openinv.commands; import com.lishid.openinv.OpenInv; import com.lishid.openinv.util.TabCompleter; +import com.lishid.openinv.util.lang.Replacement; import java.util.Collections; import java.util.List; import org.bukkit.Material; @@ -46,7 +47,10 @@ public class SearchInvCommand implements TabExecutor { } if (material == null) { - plugin.sendMessage(sender, "messages.error.invalidMaterial", "%target%", args.length > 0 ? args[0] : "null"); + plugin.sendMessage( + sender, + "messages.error.invalidMaterial", + new Replacement("%target%", args.length > 0 ? args[0] : "null")); return false; } @@ -56,7 +60,10 @@ public class SearchInvCommand implements TabExecutor { try { count = Integer.parseInt(args[1]); } catch (NumberFormatException ex) { - plugin.sendMessage(sender, "messages.error.invalidNumber", "%target%", args[1]); + plugin.sendMessage( + sender, + "messages.error.invalidNumber", + new Replacement("%target%", args[1])); return false; } } @@ -74,13 +81,18 @@ public class SearchInvCommand implements TabExecutor { if (players.length() > 0) { players.delete(players.length() - 2, players.length()); } else { - plugin.sendMessage(sender, "messages.info.player.noMatches", - "%target%", material.name()); + plugin.sendMessage( + sender, + "messages.info.player.noMatches", + new Replacement("%target%", material.name())); return true; } - plugin.sendMessage(sender, "messages.info.player.matches", - "%target%", material.name(), "%detail%", players.toString()); + plugin.sendMessage( + sender, + "messages.info.player.matches", + new Replacement("%target%", material.name()), + new Replacement("%detail%", players.toString())); return true; } diff --git a/plugin/src/main/java/com/lishid/openinv/internal/OpenInventoryView.java b/plugin/src/main/java/com/lishid/openinv/internal/OpenInventoryView.java index a7f4a95..ea7c438 100644 --- a/plugin/src/main/java/com/lishid/openinv/internal/OpenInventoryView.java +++ b/plugin/src/main/java/com/lishid/openinv/internal/OpenInventoryView.java @@ -17,6 +17,7 @@ package com.lishid.openinv.internal; import com.lishid.openinv.OpenInv; +import com.lishid.openinv.util.lang.Replacement; import java.util.Objects; import org.bukkit.entity.HumanEntity; import org.bukkit.entity.Player; @@ -73,8 +74,7 @@ public class OpenInventoryView extends InventoryView { .getLocalizedMessage( player, titleKey, - "%player%", - owner.getName()); + new Replacement("%player%", owner.getName())); title = Objects.requireNonNullElseGet(localTitle, () -> owner.getName() + titleDefaultSuffix); } diff --git a/plugin/src/main/java/com/lishid/openinv/util/LanguageManager.java b/plugin/src/main/java/com/lishid/openinv/util/LanguageManager.java deleted file mode 100644 index ff526b1..0000000 --- a/plugin/src/main/java/com/lishid/openinv/util/LanguageManager.java +++ /dev/null @@ -1,168 +0,0 @@ -/* - * Copyright (C) 2011-2022 lishid. All rights reserved. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, version 3. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package com.lishid.openinv.util; - -import com.lishid.openinv.OpenInv; -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.logging.Level; -import org.bukkit.ChatColor; -import org.bukkit.configuration.file.YamlConfiguration; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -/** - * A simple language manager supporting both custom and bundled languages. - * - * @author Jikoo - */ -public class LanguageManager { - - private final OpenInv plugin; - private final String defaultLocale; - private final Map locales; - - public LanguageManager(@NotNull OpenInv plugin, @NotNull String defaultLocale) { - this.plugin = plugin; - this.defaultLocale = defaultLocale; - this.locales = new HashMap<>(); - getOrLoadLocale(defaultLocale); - } - - private @NotNull YamlConfiguration getOrLoadLocale(@NotNull String locale) { - YamlConfiguration loaded = locales.get(locale); - if (loaded != null) { - return loaded; - } - - InputStream resourceStream = plugin.getResource("locale/" + locale + ".yml"); - YamlConfiguration localeConfigDefaults; - if (resourceStream == null) { - localeConfigDefaults = new YamlConfiguration(); - } else { - try (BufferedReader reader = new BufferedReader(new InputStreamReader(resourceStream))) { - localeConfigDefaults = YamlConfiguration.loadConfiguration(reader); - } catch (IOException e) { - plugin.getLogger().log(Level.WARNING, "[LanguageManager] Unable to load resource " + locale + ".yml", e); - localeConfigDefaults = new YamlConfiguration(); - } - } - - File file = new File(plugin.getDataFolder(), locale + ".yml"); - YamlConfiguration localeConfig; - - if (!file.exists()) { - localeConfig = localeConfigDefaults; - try { - localeConfigDefaults.save(file); - } catch (IOException e) { - plugin.getLogger().log(Level.WARNING, "[LanguageManager] Unable to save resource " + locale + ".yml", e); - } - } else { - localeConfig = YamlConfiguration.loadConfiguration(file); - - // Add new language keys - List newKeys = new ArrayList<>(); - for (String key : localeConfigDefaults.getKeys(true)) { - if (localeConfigDefaults.isConfigurationSection(key)) { - continue; - } - - if (localeConfig.isSet(key)) { - continue; - } - - localeConfig.set(key, localeConfigDefaults.get(key)); - newKeys.add(key); - } - - if (!newKeys.isEmpty()) { - plugin.getLogger().info("[LanguageManager] Added new language keys: " + String.join(", ", newKeys)); - try { - localeConfig.save(file); - } catch (IOException e) { - plugin.getLogger().log(Level.WARNING, "[LanguageManager] Unable to save resource " + locale + ".yml", e); - } - } - } - - if (!locale.equals(defaultLocale)) { - localeConfigDefaults = locales.get(defaultLocale); - - // Check for missing keys - List newKeys = new ArrayList<>(); - for (String key : localeConfigDefaults.getKeys(true)) { - if (localeConfigDefaults.isConfigurationSection(key)) { - continue; - } - - if (localeConfig.isSet(key)) { - continue; - } - - newKeys.add(key); - } - - if (!newKeys.isEmpty()) { - plugin.getLogger().info("[LanguageManager] Missing translations from " + locale + ".yml: " + String.join(", ", newKeys)); - } - - // Fall through to default locale - localeConfig.setDefaults(localeConfigDefaults); - } - - locales.put(locale, localeConfig); - return localeConfig; - } - - public @Nullable String getValue(@NotNull String key, @Nullable String locale) { - String value = getOrLoadLocale(locale == null ? defaultLocale : locale.toLowerCase()).getString(key); - if (value == null || value.isEmpty()) { - return null; - } - - value = ChatColor.translateAlternateColorCodes('&', value); - - return value; - } - - public @Nullable String getValue(@NotNull String key, @Nullable String locale, String @NotNull ... replacements) { - if (replacements.length % 2 != 0) { - plugin.getLogger().log(Level.WARNING, "[LanguageManager] Replacement data is uneven", new Exception()); - } - - String value = getValue(key, locale); - - if (value == null) { - return null; - } - - for (int i = 0; i < replacements.length; i += 2) { - value = value.replace(replacements[i], replacements[i + 1]); - } - - return value; - } - -} diff --git a/plugin/src/main/java/com/lishid/openinv/util/lang/LanguageManager.java b/plugin/src/main/java/com/lishid/openinv/util/lang/LanguageManager.java new file mode 100644 index 0000000..239031e --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/lang/LanguageManager.java @@ -0,0 +1,217 @@ +/* + * Copyright (C) 2011-2022 lishid. All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lishid.openinv.util.lang; + +import com.lishid.openinv.OpenInv; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.logging.Level; +import org.bukkit.ChatColor; +import org.bukkit.configuration.Configuration; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +/** + * A simple language manager supporting both custom and bundled languages. + * + * @author Jikoo + */ +public class LanguageManager { + + private final OpenInv plugin; + private final String defaultLocale; + private final Map locales; + + public LanguageManager(@NotNull OpenInv plugin, @NotNull String defaultLocale) { + this.plugin = plugin; + this.defaultLocale = defaultLocale; + this.locales = new HashMap<>(); + getOrLoadLocale(defaultLocale); + } + + private @NotNull YamlConfiguration getOrLoadLocale(@NotNull String locale) { + YamlConfiguration loaded = locales.get(locale); + if (loaded != null) { + return loaded; + } + + File file = new File(plugin.getDataFolder(), locale + ".yml"); + + // Load locale config from disk and bundled locale defaults. + YamlConfiguration localeConfig = loadLocale(locale, file); + + // If the locale is not the default locale, also handle any missing translations from the default locale. + if (!locale.equals(defaultLocale)) { + addTranslationFallthrough(locale, localeConfig, file); + + if (plugin.getConfig().getBoolean("settings.secret.warn-about-guess-section", true) + && localeConfig.isConfigurationSection("guess")) { + // Warn that guess section exists. This should run once per language per server restart + // when accessed by a user to hint to server owners that they can make UX improvements. + plugin.getLogger().info(() -> "[LanguageManager] Missing translations from " + locale + ".yml! Check the guess section!"); + } + } + + locales.put(locale, localeConfig); + return localeConfig; + } + + private @NotNull YamlConfiguration loadLocale( + @NotNull String locale, + @NotNull File file) { + // Load defaults from the plugin's bundled resources. + InputStream resourceStream = plugin.getResource("locale/" + locale + ".yml"); + YamlConfiguration localeConfigDefaults; + if (resourceStream == null) { + localeConfigDefaults = new YamlConfiguration(); + } else { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(resourceStream))) { + localeConfigDefaults = YamlConfiguration.loadConfiguration(reader); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to load resource " + locale + ".yml"); + localeConfigDefaults = new YamlConfiguration(); + } + } + + if (!file.exists()) { + // If the file does not exist on disk, save bundled defaults. + try { + localeConfigDefaults.save(file); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + locale + ".yml"); + } + // Return loaded bundled locale. + return localeConfigDefaults; + } + + // If the file does exist on disk, load it. + YamlConfiguration localeConfig = YamlConfiguration.loadConfiguration(file); + // Check for missing translations from the bundled file. + List newKeys = getMissingKeys(localeConfigDefaults, localeConfig::isSet); + + if (newKeys.isEmpty()) { + return localeConfig; + } + + // Get guess section for missing keys. + ConfigurationSection guess = localeConfig.getConfigurationSection("guess"); + + for (String newKey : newKeys) { + // Set all missing keys to defaults. + localeConfig.set(newKey, localeConfigDefaults.get(newKey)); + + // Delete relevant guess keys in case this is a new translation. + if (guess != null) { + guess.set(newKey, null); + } + } + + // If guess section is empty, delete it. + if (guess != null && guess.getKeys(false).isEmpty()) { + localeConfig.set("guess", null); + } + + plugin.getLogger().info(() -> "[LanguageManager] Added new translation keys to " + locale + ".yml: " + String.join(", ", newKeys)); + + // Write new keys to disk. + try { + localeConfig.save(file); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + locale + ".yml"); + } + + return localeConfig; + } + + private void addTranslationFallthrough( + @NotNull String locale, + @NotNull YamlConfiguration localeConfig, + @NotNull File file) { + YamlConfiguration defaultLocaleConfig = locales.get(defaultLocale); + + // Get missing keys. Keys that already have a guess value are not new and don't need to trigger another write. + List missingKeys = getMissingKeys( + defaultLocaleConfig, + key -> localeConfig.isSet(key) || localeConfig.isSet("guess." + key)); + + if (!missingKeys.isEmpty()) { + // Set up guess section for missing keys. + for (String key : missingKeys) { + localeConfig.set("guess." + key, defaultLocaleConfig.get(key)); + } + + // Write modified guess section to disk. + try { + localeConfig.save(file); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, e, () -> "[LanguageManager] Unable to save resource " + locale + ".yml"); + } + } + + // Fall through to default locale. + localeConfig.setDefaults(defaultLocaleConfig); + } + + private @NotNull List getMissingKeys( + @NotNull Configuration configurationDefault, + @NotNull Predicate nodeSetPredicate) { + List missingKeys = new ArrayList<>(); + for (String key : configurationDefault.getKeys(true)) { + if (!configurationDefault.isConfigurationSection(key) && !nodeSetPredicate.test(key)) { + // Missing keys are non-section keys that fail the predicate. + missingKeys.add(key); + } + } + return missingKeys; + } + + public @Nullable String getValue(@NotNull String key, @Nullable String locale) { + String value = getOrLoadLocale(locale == null ? defaultLocale : locale.toLowerCase()).getString(key); + if (value == null || value.isEmpty()) { + return null; + } + + value = ChatColor.translateAlternateColorCodes('&', value); + + return value; + } + + public @Nullable String getValue(@NotNull String key, @Nullable String locale, Replacement @NotNull ... replacements) { + String value = getValue(key, locale); + + if (value == null) { + return null; + } + + for (Replacement replacement : replacements) { + value = value.replace(replacement.placeholder(), replacement.value()); + } + + return value; + } + +} diff --git a/plugin/src/main/java/com/lishid/openinv/util/lang/Replacement.java b/plugin/src/main/java/com/lishid/openinv/util/lang/Replacement.java new file mode 100644 index 0000000..6753044 --- /dev/null +++ b/plugin/src/main/java/com/lishid/openinv/util/lang/Replacement.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2011-2022 lishid. All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, version 3. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package com.lishid.openinv.util.lang; + +import org.jetbrains.annotations.NotNull; + +/** + * A data holder for string replacement in translations. + * + * @param placeholder the placeholder to be replaced + * @param value the value to insert + */ +public record Replacement(@NotNull String placeholder, @NotNull String value) { + +}