diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-09-28 23:43:13 +0900 |
|---|---|---|
| committer | Sho Sakuma <me@m1sk9.dev> | 2026-09-28 23:43:13 +0900 |
| commit | a22fce70db7b43aadd8baa42e87a4430a8b1e05f (patch) | |
| tree | 4cb170c50669db3ca7e046b8447294e8201146cc | |
| parent | 479000d491f8b1363a1e6a6e8f6fac85e0fc74bd (diff) | |
| download | LunaticChat-a22fce70db7b43aadd8baa42e87a4430a8b1e05f.tar.gz LunaticChat-a22fce70db7b43aadd8baa42e87a4430a8b1e05f.tar.bz2 LunaticChat-a22fce70db7b43aadd8baa42e87a4430a8b1e05f.zip | |
fix(paper): splice display names as components and share role labels
Serializing the display name into the legacy text let its color run into
everything after it, and legacy text cannot hold hex colors or hover and
click events, so names styled by other plugins came out wrong. The name is
now spliced into the parsed component instead.
{role} is resolved only when the format uses it, since the default
formats do not and it costs a member scan and a lookup per message.
/lc channel status now shows the same localized role names instead of a
second, hard-coded [OWNER] / [MOD] vocabulary that only one of the two
places translated.
Co-Authored-By: Claude <noreply@anthropic.com>
9 files changed, 178 insertions, 36 deletions
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt index b9a8239..82430a9 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt @@ -13,9 +13,9 @@ import dev.m1sk9.lunaticChat.paper.common.playMessageSendNotification import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager import dev.m1sk9.lunaticChat.paper.i18n.channelMessage +import dev.m1sk9.lunaticChat.paper.i18n.channelRoleLabel import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager import net.kyori.adventure.text.Component -import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer import org.bukkit.Bukkit import org.bukkit.entity.Player @@ -98,18 +98,12 @@ class ChannelMessageHandler( sender = sender.name, channel = context.channel.name, channelId = context.channel.id, - displayName = LegacyComponentSerializer.legacySection().serialize(sender.displayName()), + displayName = sender.displayName(), message = message, world = sender.world.name, - role = roleLabel(context.members.firstOrNull { it.playerId == sender.uniqueId }?.role), - ) - - private fun roleLabel(role: ChannelRole?): String = - languageManager.getMessage( - when (role) { - ChannelRole.OWNER -> "channel.role.owner" - ChannelRole.MODERATOR -> "channel.role.moderator" - ChannelRole.MEMBER, null -> "channel.role.member" + role = { + val role = context.members.firstOrNull { it.playerId == sender.uniqueId }?.role ?: ChannelRole.MEMBER + languageManager.channelRoleLabel(role) }, ) } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt index ae2ebfe..3b14959 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt @@ -12,6 +12,7 @@ import dev.m1sk9.lunaticChat.paper.command.core.CommandContext import dev.m1sk9.lunaticChat.paper.command.core.LunaticSubCommand import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager import dev.m1sk9.lunaticChat.paper.i18n.MessageFormatter +import dev.m1sk9.lunaticChat.paper.i18n.channelRoleLabel import io.papermc.paper.command.brigadier.CommandSourceStack import io.papermc.paper.command.brigadier.Commands import net.kyori.adventure.text.Component @@ -90,9 +91,8 @@ class ChannelStatusCommand( val playerName = Bukkit.getOfflinePlayer(member.playerId).name ?: return@mapNotNull null val roleText = when (member.role) { - ChannelRole.OWNER -> " [OWNER]" - ChannelRole.MODERATOR -> " [MOD]" ChannelRole.MEMBER -> "" + else -> " [${languageManager.channelRoleLabel(member.role)}]" } playerName + roleText } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChannelRoleLabel.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChannelRoleLabel.kt new file mode 100644 index 0000000..c0d3e0a --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChannelRoleLabel.kt @@ -0,0 +1,13 @@ +package dev.m1sk9.lunaticChat.paper.i18n + +import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole + +/** The name of [role] in the configured language, as both `{role}` and `/lc channel status` show it. */ +fun LanguageManager.channelRoleLabel(role: ChannelRole): String = + getMessage( + when (role) { + ChannelRole.OWNER -> "channel.role.owner" + ChannelRole.MODERATOR -> "channel.role.moderator" + ChannelRole.MEMBER -> "channel.role.member" + }, + ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt index 85e6b52..f65bb96 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt @@ -2,6 +2,7 @@ package dev.m1sk9.lunaticChat.paper.i18n import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextReplacementConfig import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer /** @@ -30,21 +31,21 @@ fun MessageFormatConfig.channelMessage( sender: String, channel: String, channelId: String, - displayName: String, + displayName: Component, message: String, world: String, - role: String, + role: () -> String, ): Component = channelMessageFormat.render( mapOf( ChatPlaceholder.SENDER to sender, ChatPlaceholder.CHANNEL to channel, ChatPlaceholder.CHANNEL_ID to channelId, - ChatPlaceholder.DISPLAY_NAME to displayName, ChatPlaceholder.MESSAGE to message, ChatPlaceholder.WORLD to world, - ChatPlaceholder.ROLE to role, ), + deferred = mapOf(ChatPlaceholder.ROLE to role), + components = mapOf(ChatPlaceholder.DISPLAY_NAME to displayName), ) fun MessageFormatConfig.crossServerGlobalChat( @@ -60,15 +61,46 @@ fun MessageFormatConfig.crossServerGlobalChat( ), ) -private val PLACEHOLDER = Regex("""\{([A-Za-z_]+)}""") +internal val CHAT_PLACEHOLDER_PATTERN = Regex("""\{([A-Za-z_]+)}""") // One pass over the format rather than one replace per placeholder, so a value that itself // contains "{message}" (a channel named that way, say) is inserted literally instead of being // substituted again by the next replace. -private fun String.render(values: Map<ChatPlaceholder, String>): Component { +// +// Component values are spliced in after parsing instead of being serialized into the legacy text: +// legacy codes have no closing tag, so a colored display name would recolor everything after it, +// and legacy text cannot hold hex colors or hover and click events at all. +// +// Deferred values are computed only when the format uses them, since the default formats leave +// out the ones that cost a lookup on every message. +private fun String.render( + values: Map<ChatPlaceholder, String>, + deferred: Map<ChatPlaceholder, () -> String> = emptyMap(), + components: Map<ChatPlaceholder, Component> = emptyMap(), +): Component { + val spliced = mutableSetOf<ChatPlaceholder>() + val resolved = mutableMapOf<ChatPlaceholder, String>() val text = - replace(PLACEHOLDER) { match -> - ChatPlaceholder.fromKey(match.groupValues[1])?.let(values::get) ?: match.value + replace(CHAT_PLACEHOLDER_PATTERN) { match -> + when (val placeholder = ChatPlaceholder.fromKey(match.groupValues[1])) { + null -> match.value + in components -> placeholder.marker.also { spliced += placeholder } + in deferred -> resolved.getOrPut(placeholder) { deferred.getValue(placeholder)() } + else -> values[placeholder] ?: match.value + } } - return LegacyComponentSerializer.legacySection().deserialize(text) + val parsed: Component = LegacyComponentSerializer.legacySection().deserialize(text) + return spliced.fold(parsed) { rendered, placeholder -> + rendered.replaceText( + TextReplacementConfig + .builder() + .matchLiteral(placeholder.marker) + .replacement(components.getValue(placeholder)) + .build(), + ) + } } + +// NUL rather than a printable token: the client refuses to send control characters, so no value +// substituted as text - a message, a channel name - can contain the marker and be replaced by it. +private val ChatPlaceholder.marker: String get() = "\u0000$key\u0000" diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt index 24f2897..57f60da 100644 --- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt @@ -124,7 +124,40 @@ class ChannelMessageHandlerTest { handler().sendChannelMessage(owner, "hello") verify { member.sendMessage(capture(delivered)) } - assertEquals("ch-1|§cQueen|world_nether|Owner", LegacyComponentSerializer.legacySection().serialize(delivered.captured)) + assertEquals("ch-1|§cQueen§r|world_nether|Owner", LegacyComponentSerializer.legacySection().serialize(delivered.captured)) + } + + @Test + fun `a moderator's role renders as the moderator label`() { + every { channelManager.getPlayerChannelContext(senderId) } returns + ChannelContext( + channel = channel, + members = + listOf( + TestUtils.createTestChannelMember(channelId = channel.id, playerId = senderId, role = ChannelRole.MODERATOR), + TestUtils.createTestChannelMember(channelId = channel.id, playerId = memberId), + ), + ) + every { languageManager.getMessage("channel.role.moderator") } returns "Moderator" + messageFormats.replace(MessageFormatConfig(channelMessageFormat = "{role}")) + val delivered = slot<Component>() + + handler().sendChannelMessage(sender, "hello") + + verify { member.sendMessage(capture(delivered)) } + assertEquals("Moderator", LegacyComponentSerializer.legacySection().serialize(delivered.captured)) + } + + @Test + fun `a regular member's role renders as the member label`() { + every { languageManager.getMessage("channel.role.member") } returns "Member" + messageFormats.replace(MessageFormatConfig(channelMessageFormat = "{role}")) + val delivered = slot<Component>() + + handler().sendChannelMessage(sender, "hello") + + verify { member.sendMessage(capture(delivered)) } + assertEquals("Member", LegacyComponentSerializer.legacySection().serialize(delivered.captured)) } @Test diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt index bbcfafb..1b26538 100644 --- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt @@ -1,5 +1,6 @@ package dev.m1sk9.lunaticChat.paper.command.impl.lc.channel +import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole import dev.m1sk9.lunaticChat.engine.command.CommandResult import dev.m1sk9.lunaticChat.paper.LunaticChat import dev.m1sk9.lunaticChat.paper.TestUtils @@ -11,11 +12,14 @@ import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkStatic +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import org.bukkit.Bukkit import org.bukkit.OfflinePlayer import java.util.UUID import kotlin.test.Test import kotlin.test.assertIs +import kotlin.test.assertTrue class ChannelStatusCommandTest { private val testUUID = UUID.fromString("00000001-0000-0000-0000-000000000000") @@ -78,6 +82,32 @@ class ChannelStatusCommandTest { } @Test + fun `members are listed with their role in the configured language`() { + val deps = createDependencies() + every { deps.languageManager.getMessage("channel.role.owner", any()) } returns "オーナー" + val channel = TestUtils.createTestChannel(id = channelId, ownerId = testUUID) + val members = listOf(TestUtils.createTestChannelMember(channelId = channelId, playerId = testUUID, role = ChannelRole.OWNER)) + every { deps.channelManager.getPlayerChannel(testUUID) } returns channelId + every { deps.channelManager.getChannel(channelId) } returns Result.success(channel) + every { deps.channelManager.getChannelMembers(channelId) } returns Result.success(members) + every { deps.membershipManager.getPlayerChannels(testUUID) } returns listOf(channelId) + val sent = mutableListOf<Component>() + every { deps.mockPlayer.sendMessage(capture(sent)) } returns Unit + + mockkStatic(Bukkit::class) + try { + mockBukkitOfflinePlayer() + + deps.command.execute(deps.ctx) + } finally { + unmockkStatic(Bukkit::class) + } + + val shown = sent.joinToString("\n") { PlainTextComponentSerializer.plainText().serialize(it) } + assertTrue("Player1 [オーナー]" in shown, shown) + } + + @Test fun `execute without active channel should return Success`() { val deps = createDependencies() diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt index c977fd1..666a17e 100644 --- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt +++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt @@ -2,25 +2,34 @@ package dev.m1sk9.lunaticChat.paper.i18n import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.event.HoverEvent import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextColor import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer import kotlin.reflect.full.memberProperties import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class ChatFormatTest { private val defaults = MessageFormatConfig() - private val placeholderPattern = Regex("""\{([A-Za-z_]+)}""") + private val queen = Component.text("Queen") private fun Component.legacy(): String = LegacyComponentSerializer.legacySection().serialize(this) + private fun Component.leafContaining(text: String): Component = + generateSequence(listOf(this)) { level -> level.flatMap { it.children() }.ifEmpty { null } } + .flatten() + .first { it is TextComponent && text in it.content() } + private fun MessageFormatConfig.renderAll(): List<String> = listOf( directMessage("Alice", "Bob", "hi", "world"), - channelMessage("Alice", "general", "general", "Queen", "hi", "world", "Owner"), + channelMessage("Alice", "general", "general", queen, "hi", "world", { "Owner" }), crossServerGlobalChat("lobby", "Alice", "hi"), ).map { it.legacy() } @@ -33,7 +42,7 @@ class ChatFormatTest { fun `the default channel message format renders channel, sender and message`() { assertEquals( "§7[§b#general§7] §eAlice: §fhi", - defaults.channelMessage("Alice", "general", "general", "Queen", "hi", "world", "Owner").legacy(), + defaults.channelMessage("Alice", "general", "general", queen, "hi", "world", { "Owner" }).legacy(), ) } @@ -55,18 +64,49 @@ class ChatFormatTest { assertEquals( "general-1 Queen world_nether Owner", - formats.channelMessage("Alice", "General", "general-1", "Queen", "hi", "world_nether", "Owner").legacy(), + formats.channelMessage("Alice", "General", "general-1", queen, "hi", "world_nether", { "Owner" }).legacy(), ) } @Test - fun `section sign codes in a display name become styles`() { + fun `a display name keeps its own color without recoloring the rest of the format`() { + val formats = MessageFormatConfig(channelMessageFormat = "{display_name}: {message}") + val displayName = Component.text("Queen", NamedTextColor.RED) + + val rendered = formats.channelMessage("Alice", "general", "general", displayName, "hi", "world", { "Owner" }) + + assertEquals("Queen: hi", PlainTextComponentSerializer.plainText().serialize(rendered)) + assertEquals(NamedTextColor.RED, rendered.leafContaining("Queen").color()) + assertNull(rendered.leafContaining(": hi").color()) + } + + @Test + fun `a display name without a color of its own takes the format's color`() { + val formats = MessageFormatConfig(channelMessageFormat = "§e{display_name}") + + assertEquals("§eQueen", formats.channelMessage("Alice", "general", "general", queen, "hi", "world", { "Owner" }).legacy()) + } + + @Test + fun `a display name keeps hex colors and hover events`() { val formats = MessageFormatConfig(channelMessageFormat = "{display_name}") + val hover = HoverEvent.showText(Component.text("Level 42")) + val displayName = Component.text("Queen", TextColor.color(0x12AB34)).hoverEvent(hover) + + val rendered = formats.channelMessage("Alice", "general", "general", displayName, "hi", "world", { "Owner" }) + + val queenLeaf = rendered.leafContaining("Queen") + assertEquals(TextColor.color(0x12AB34), queenLeaf.color()) + assertEquals(hover, queenLeaf.hoverEvent()) + } + + @Test + fun `the role is not computed when the format does not use it`() { + var computed = false - val rendered = formats.channelMessage("Alice", "general", "general", "§cQueen", "hi", "world", "Owner") + defaults.channelMessage("Alice", "general", "general", queen, "hi", "world") { "Owner".also { computed = true } } - assertEquals("Queen", PlainTextComponentSerializer.plainText().serialize(rendered)) - assertEquals(NamedTextColor.RED, rendered.color()) + assertFalse(computed) } @Test @@ -80,7 +120,7 @@ class ChatFormatTest { fun `a value containing a placeholder is inserted literally`() { val formats = MessageFormatConfig(channelMessageFormat = "<{channel}> {message}") - assertEquals("<{message}> hi", formats.channelMessage("Alice", "{message}", "general", "Queen", "hi", "world", "Owner").legacy()) + assertEquals("<{message}> hi", formats.channelMessage("Alice", "{message}", "general", queen, "hi", "world", { "Owner" }).legacy()) } @Test @@ -102,7 +142,7 @@ class ChatFormatTest { fun `every placeholder in the default formats is a known one`() { MessageFormatConfig::class.memberProperties.forEach { property -> val format = property.get(defaults) as String - placeholderPattern.findAll(format).forEach { + CHAT_PLACEHOLDER_PATTERN.findAll(format).forEach { val name = it.groupValues[1] assertTrue(ChatPlaceholder.fromKey(name) != null, "${property.name} uses unknown {$name}") } @@ -133,6 +173,6 @@ class ChatFormatTest { val keys = ChatPlaceholder.entries.map { it.key } assertEquals(keys.size, keys.toSet().size) - keys.forEach { assertTrue(Regex("[A-Za-z_]+").matches(it), "$it cannot be matched by the renderer") } + keys.forEach { assertTrue(CHAT_PLACEHOLDER_PATTERN.matches("{$it}"), "$it cannot be matched by the renderer") } } } diff --git a/website/src/docs/developers/platform-paper.md b/website/src/docs/developers/platform-paper.md index 084d23f..6e6b7f4 100644 --- a/website/src/docs/developers/platform-paper.md +++ b/website/src/docs/developers/platform-paper.md @@ -158,7 +158,7 @@ Channel state itself is managed by the `chat/channel` package. - `Language` (enum) — `EN` / `JA`; unknown codes fall back to EN - `LanguageManager` — loads `resources/languages/` with KAML at startup and flattens the nested YAML into dotted keys (`toggle.on`, etc.). `getMessage(key, placeholders)` resolves with selected-language → EN fallback and substitutes `{placeholder}`, returning the key itself if not found. A missing EN is a fatal error - `MessageFormatter` (`object`) — produces an Adventure `Component` with a `[LC]` prefix and highlights `{braces}` placeholders detected by regex -- `ChatPlaceholder` / `ChatFormat.kt` — the placeholder vocabulary of `messageFormat` and its one renderer: entry points per format (`directMessage` / `channelMessage` / `crossServerGlobalChat`) substitute in a single pass and hand the result to `LegacyComponentSerializer`, so handlers never spell a placeholder name +- `ChatPlaceholder` / `ChatFormat.kt` — the placeholder vocabulary of `messageFormat` and its one renderer: entry points per format (`directMessage` / `channelMessage` / `crossServerGlobalChat`) substitute in a single pass and hand the result to `LegacyComponentSerializer`, then splice in Component values such as `{display_name}` so their styles and events stay scoped to them. Handlers never spell a placeholder name ## converter — Romaji-to-Japanese conversion diff --git a/website/src/ja/docs/developers/platform-paper.md b/website/src/ja/docs/developers/platform-paper.md index 930c696..edcd858 100644 --- a/website/src/ja/docs/developers/platform-paper.md +++ b/website/src/ja/docs/developers/platform-paper.md @@ -158,7 +158,7 @@ config フラグ - `Language` (enum) — `EN` / `JA`.未知コードは EN にフォールバック - `LanguageManager` — 起動時に `resources/languages/` を KAML でロードし,ネストした YAML をドット記法 (`toggle.on` 等) にフラット化する.`getMessage(key, placeholders)` は 選択言語 → EN フォールバック で解決し `{placeholder}` を置換,未発見はキー自身を返す.EN が無ければ致命エラー - `MessageFormatter` (`object`) — `[LC]` プレフィックス付きの Adventure `Component` を生成し,`{braces}` プレースホルダを正規表現で検出して色分けする -- `ChatPlaceholder` / `ChatFormat.kt` — `messageFormat` のプレースホルダ語彙と唯一のレンダラ.フォーマットごとの入口 (`directMessage` / `channelMessage` / `crossServerGlobalChat`) が 1 パスで置換し,結果を `LegacyComponentSerializer` に渡す.ハンドラがプレースホルダ名を綴ることはない +- `ChatPlaceholder` / `ChatFormat.kt` — `messageFormat` のプレースホルダ語彙と唯一のレンダラ.フォーマットごとの入口 (`directMessage` / `channelMessage` / `crossServerGlobalChat`) が 1 パスで置換し,結果を `LegacyComponentSerializer` に渡したうえで,`{display_name}` のような Component 値を差し込む (スタイルやイベントがその値の範囲に閉じるように).ハンドラがプレースホルダ名を綴ることはない ## converter — ローマ字→日本語変換 |
