Generate webHtmlApp's strings, collapse a redundant string key

Strings.kt is now generated at build time (:webHtmlApp:generateStrings,
in webHtmlApp/build.gradle.kts) by parsing shared's strings.xml
directly, rather than a hand-maintained duplicate that could silently
drift from it. Names are mechanical camelCase of the XML key, so a few
call sites got more verbose (e.g. moveUp -> moveCurrencyUpContentDescription)
in exchange for zero manual sync. Only covers the base English locale.

Also collapses currency_option_label_local into currency_option_label:
the two had identical English text and existed only as a hook for a
future translation to phrase the user's own currency differently, which
no locale currently does. Removes the info.code == localeCurrencyCode
branching (and the now-pointless localeCurrencyCode parameter/field) from
all three UI implementations - PriceScreen.kt, CurrencyPickerSheet.swift,
and HtmlCurrencyPicker.kt - plus IosConverterState.localeCurrencyCode,
which existed solely to feed that branch on the Swift side.

Fixes a real regression surfaced while verifying the above by actually
building the Xcode project (not done since currencyFlagEmoji() gained
its supportsFlagEmoji parameter): Kotlin/Native doesn't generate
default-parameter overloads for Swift/ObjC-exported functions, so both
Swift call sites needed the argument passed explicitly, via the correct
exported facade name for an expect/actual pair - the actual file's
(SystemCurrencies_appleKt), not the common one (SystemCurrenciesKt).

Also drops "(DOM)" from webHtmlApp's page title.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-17 07:33:47 +03:00
co-authored by Claude Sonnet 5
parent 188536867d
commit 8057651688
11 changed files with 127 additions and 97 deletions
+12 -6
View File
@@ -112,12 +112,18 @@ the pattern to copy: it returns a locale-formatted `String?` (using
locale-aware formatting via platform APIs, not translated text), and each
UI layer wraps it with its own localized "Updated %1$s" string.
`webHtmlApp` is the one UI layer that deliberately breaks this pattern: its
`Strings.kt` hardcodes English rather than calling `stringResource()`,
because moko-resources' Compose integration (`moko-resourcesCompose`) pulls
in `compose.foundation`/`compose.ui` transitively - exactly the dependency
`webHtmlApp` exists to avoid (see "Module layout" above). Not a template to
follow elsewhere; a one-off tradeoff specific to that module.
`webHtmlApp` is the one UI layer that deliberately breaks this pattern: it
can't call `stringResource()` because moko-resources' Compose integration
(`moko-resourcesCompose`) pulls in `compose.foundation`/`compose.ui`
transitively - exactly the dependency `webHtmlApp` exists to avoid (see
"Module layout" above). Its `Strings.kt` is instead **generated** at build
time (`:webHtmlApp:generateStrings`, in `webHtmlApp/build.gradle.kts`) by
parsing `shared/.../moko-resources/base/strings.xml` directly, so that file
stays the single source of truth instead of a manually maintained,
silently driftable duplicate - edit the XML, not the generated file. Only
covers the base (English) locale, since it skips moko's per-locale
resolution entirely. Not a template to follow elsewhere; a one-off
tradeoff specific to that module.
## Locale-aware formatting
+1 -2
View File
@@ -274,7 +274,6 @@ struct ContentView: View {
unselectedCurrencies: state.unselectedCurrencies,
pricedCurrencyCodes: state.pricedCurrencyCodes,
sourceName: state.sourceName,
localeCurrencyCode: state.localeCurrencyCode,
selectedCount: state.selectedCurrencyCodes.count,
onToggle: { viewModel.onFiatCurrencyToggled($0) },
onReset: { viewModel.onSelectedCurrenciesReset() }
@@ -404,7 +403,7 @@ private struct NumericField: View {
}
private func currencyFieldLabel(for code: String) -> String {
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code) {
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) {
return "\(flag) \(code)"
}
return code
+5 -14
View File
@@ -7,7 +7,6 @@ struct CurrencyPickerSheet: View {
let unselectedCurrencies: [CurrencyInfo]
let pricedCurrencyCodes: [String]
let sourceName: String
let localeCurrencyCode: String?
let selectedCount: Int
let onToggle: (String) -> Void
let onReset: () -> Void
@@ -142,19 +141,11 @@ struct CurrencyPickerSheet: View {
}
private func currencyLabel(for info: CurrencyInfo) -> String {
let text: String
if info.code == localeCurrencyCode {
text = IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currency_option_label_local,
args: [info.code, info.displayName]
)
} else {
text = IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currency_option_label,
args: [info.code, info.displayName]
)
}
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code) {
let text = IosLocalizationKt.localizedFormattedString(
resource: MR.strings.shared.currency_option_label,
args: [info.code, info.displayName]
)
if let flag = CurrencyFlagKt.currencyFlagEmoji(code: info.code, supportsFlagEmoji: SystemCurrencies_appleKt.supportsFlagEmoji()) {
return "\(flag) \(text)"
}
return text
@@ -26,7 +26,6 @@ data class IosConverterState(
val unselectedCurrencies: List<CurrencyInfo>,
val selectedCurrencyCodes: List<String>,
val pricedCurrencyCodes: List<String>,
val localeCurrencyCode: String?,
val defaultCurrencyCode: String,
val sourceName: String,
val isManualSource: Boolean,
@@ -81,7 +80,6 @@ private fun ConverterUiState.toIosState(): IosConverterState = IosConverterState
unselectedCurrencies = unselectedCurrencies(),
selectedCurrencyCodes = selectedFiatCurrencies,
pricedCurrencyCodes = pricedCurrencyCodes.toList(),
localeCurrencyCode = localeCurrencyCode,
defaultCurrencyCode = defaultCurrencyCode,
sourceName = sourceName,
isManualSource = isManualSource,
@@ -10,7 +10,6 @@
<string name="current_currency_section_title">Current Currency</string>
<string name="currency_not_priced">Not priced by %1$s</string>
<string name="currency_option_label">%1$s - %2$s</string>
<string name="currency_option_label_local">%1$s - %2$s</string>
<string name="currency_options_content_description">Options for %1$s</string>
<string name="currency_to_sats">%1$s to Sats</string>
<string name="done">Done</string>
@@ -605,7 +605,6 @@ private fun CurrencyPickerScreen(
isSelected = true,
isPriced = state.isPriced(currentCurrency.code),
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = null,
)
}
@@ -625,7 +624,6 @@ private fun CurrencyPickerScreen(
isSelected = true,
isPriced = state.isPriced(info.code),
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
@@ -649,7 +647,6 @@ private fun CurrencyPickerScreen(
isSelected = false,
isPriced = true,
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
@@ -671,7 +668,6 @@ private fun CurrencyPickerScreen(
isSelected = false,
isPriced = false,
sourceName = state.sourceName,
localeCurrencyCode = state.localeCurrencyCode,
onClick = { onToggle(info.code) },
)
}
@@ -689,14 +685,9 @@ private fun CurrencyRow(
isSelected: Boolean,
isPriced: Boolean,
sourceName: String,
localeCurrencyCode: String?,
onClick: (() -> Unit)?,
) {
val text = if (info.code == localeCurrencyCode) {
stringResource(MR.strings.currency_option_label_local, info.code, info.displayName)
} else {
stringResource(MR.strings.currency_option_label, info.code, info.displayName)
}
val text = stringResource(MR.strings.currency_option_label, info.code, info.displayName)
val label = currencyFlagEmoji(info.code)?.let { flag -> "$flag $text" } ?: text
Row(
modifier = Modifier
+90 -4
View File
@@ -1,9 +1,92 @@
import org.w3c.dom.Element
import javax.xml.parsers.DocumentBuilderFactory
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
// webHtmlApp can't use moko-resources' Compose integration (stringResource()) without pulling
// in compose.foundation/ui transitively - exactly what this module exists to avoid (see
// AGENTS.md's "Module layout"). Generating a plain Strings.kt from the same strings.xml moko
// reads keeps that file as the single source of truth without a manually maintained, silently
// driftable duplicate. Only covers the base (English) locale - see AGENTS.md.
val stringsXmlFile = rootProject.file("shared/src/commonMain/moko-resources/base/strings.xml")
val generateStrings by tasks.registering {
val outputDir = layout.buildDirectory.dir("generated/strings")
// Captured as a plain local (rather than read from the doLast closure directly) so the task
// action doesn't hold a reference to the build script object itself, which the configuration
// cache refuses to serialize.
val xmlFile = stringsXmlFile
inputs.file(xmlFile)
outputs.dir(outputDir)
doLast {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(xmlFile)
val stringNodes = document.getElementsByTagName("string")
val placeholderPattern = Regex("""%(\d+)\$([sd])""")
fun String.toCamelCase(): String = split("_").mapIndexed { index, word ->
if (index == 0) word else word.replaceFirstChar { it.uppercase() }
}.joinToString("")
val source = buildString {
appendLine("// GENERATED FILE - do not edit directly.")
appendLine("// Regenerated by :webHtmlApp:generateStrings from")
appendLine("// shared/src/commonMain/moko-resources/base/strings.xml - edit that file instead.")
appendLine("package xyz.tyiu.satsprice")
appendLine()
appendLine("object Strings {")
for (i in 0 until stringNodes.length) {
val element = stringNodes.item(i) as Element
val propertyName = element.getAttribute("name").toCamelCase()
// moko/Android string-resource XML escapes a literal apostrophe as \' inside the
// (unquoted) text content - unescape that before anything else so it doesn't get
// mistaken for a Kotlin escape sequence below.
val rawValue = element.textContent.replace("\\'", "'")
val placeholders = placeholderPattern.findAll(rawValue)
.map { it.groupValues[1].toInt() to it.groupValues[2] }
.distinct()
.sortedBy { it.first }
.toList()
// Swap each %N$s/%N$d placeholder for a control-character sentinel first, so the
// literal '$' in "%1$s" itself doesn't get caught by the blanket '$' -> "\$"
// escaping below - then escape, then turn the sentinels into "${argN}".
var withSentinels = rawValue
for ((index, _) in placeholders) {
withSentinels = withSentinels.replace(Regex("""%$index\$[sd]"""), "$index")
}
val escaped = withSentinels
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("$", "\\$")
var templateBody = escaped
for ((index, _) in placeholders) {
templateBody = templateBody.replace("$index", "\${arg$index}")
}
if (placeholders.isEmpty()) {
appendLine(" const val $propertyName: String = \"$templateBody\"")
} else {
val params = placeholders.joinToString(", ") { (index, type) ->
"arg$index: ${if (type == "d") "Int" else "String"}"
}
appendLine(" fun $propertyName($params): String = \"$templateBody\"")
}
}
appendLine("}")
}
val outputFile = outputDir.get().file("xyz/tyiu/satsprice/Strings.kt").asFile
outputFile.parentFile.mkdirs()
outputFile.writeText(source)
}
}
// Compose HTML (org.jetbrains.compose.html:html-core) only publishes js and jvm target
// variants as of Compose Multiplatform 1.11.1 - no wasmJs artifact exists yet, unlike the
// Skia-based Compose Multiplatform UI toolkit webApp uses (which targets both). So this
@@ -15,11 +98,14 @@ kotlin {
}
sourceSets {
commonMain.dependencies {
implementation(project(":shared"))
commonMain {
kotlin.srcDir(generateStrings)
dependencies {
implementation(project(":shared"))
implementation(libs.compose.runtime)
implementation(libs.compose.html.core)
implementation(libs.compose.runtime)
implementation(libs.compose.html.core)
}
}
}
}
@@ -71,8 +71,8 @@ fun HtmlApp() {
} else {
Button(attrs = {
classes("icon-button")
attr("aria-label", Strings.refresh)
attr("title", Strings.refresh)
attr("aria-label", Strings.refreshContentDescription)
attr("title", Strings.refreshContentDescription)
onClick { viewModel.refresh() }
}) { Text("") }
}
@@ -223,7 +223,7 @@ private fun CurrencyRowMenu(
Div({ classes("menu-anchor") }) {
Button(attrs = {
classes("icon-button")
attr("aria-label", Strings.currencyOptions(code))
attr("aria-label", Strings.currencyOptionsContentDescription(code))
attr("aria-haspopup", "true")
attr("aria-expanded", if (expanded) "true" else "false")
onClick { expanded = true }
@@ -235,12 +235,15 @@ private fun CurrencyRowMenu(
onClick { expanded = false }
})
Div({ classes("menu-popup") }) {
MenuItem(Strings.moveToTop, enabled = canMoveUp) { expanded = false; onMoveToTop() }
MenuItem(Strings.moveUp, enabled = canMoveUp) { expanded = false; onMoveUp() }
MenuItem(Strings.moveDown, enabled = canMoveDown) { expanded = false; onMoveDown() }
MenuItem(Strings.moveToBottom, enabled = canMoveDown) { expanded = false; onMoveToBottom() }
MenuItem(Strings.moveCurrencyToTopContentDescription, enabled = canMoveUp) { expanded = false; onMoveToTop() }
MenuItem(Strings.moveCurrencyUpContentDescription, enabled = canMoveUp) { expanded = false; onMoveUp() }
MenuItem(Strings.moveCurrencyDownContentDescription, enabled = canMoveDown) { expanded = false; onMoveDown() }
MenuItem(Strings.moveCurrencyToBottomContentDescription, enabled = canMoveDown) {
expanded = false
onMoveToBottom()
}
if (canRemove) {
MenuItem(Strings.remove, destructive = true) { expanded = false; onRemove() }
MenuItem(Strings.removeCurrencyContentDescription, destructive = true) { expanded = false; onRemove() }
}
}
}
@@ -30,8 +30,8 @@ internal fun HtmlCurrencyPicker(
if (showResetConfirmation) {
Div({ classes("modal-overlay") }) {
Div({ classes("modal") }) {
H2 { Text(Strings.resetConfirmationTitle) }
Div { Text(Strings.resetConfirmationMessage(state.defaultCurrencyCode)) }
H2 { Text(Strings.resetSelectedCurrenciesConfirmationTitle) }
Div { Text(Strings.resetSelectedCurrenciesConfirmationMessage(state.defaultCurrencyCode)) }
Div({ classes("row", "modal-actions") }) {
Button(attrs = { onClick { showResetConfirmation = false } }) { Text(Strings.cancel) }
Button(attrs = {
@@ -40,7 +40,7 @@ internal fun HtmlCurrencyPicker(
showResetConfirmation = false
onReset()
}
}) { Text(Strings.resetButton) }
}) { Text(Strings.resetSelectedCurrenciesButton) }
}
}
}
@@ -51,7 +51,9 @@ internal fun HtmlCurrencyPicker(
H2 { Text(Strings.currenciesSectionTitle) }
Div({ classes("row") }) {
if (state.selectedFiatCurrencies.size > 1) {
Button(attrs = { onClick { showResetConfirmation = true } }) { Text(Strings.resetButton) }
Button(attrs = { onClick { showResetConfirmation = true } }) {
Text(Strings.resetSelectedCurrenciesButton)
}
}
Button(attrs = { onClick { onDone() } }) { Text(Strings.done) }
}
@@ -1,45 +0,0 @@
package xyz.tyiu.satsprice
/**
* English-only string constants mirroring shared/src/commonMain/moko-resources/base/strings.xml.
* moko-resources-compose's `stringResource()` pulls in Compose Foundation/UI (for its
* `LocalComposition`/`Modifier` plumbing) transitively, which would drag Skia-oriented
* dependencies into a module whose whole point is avoiding them, so this DOM build skips
* moko-resources rather than fight that dependency and stays English-only as a result.
*/
object Strings {
const val addCurrency = "Add currency"
const val bitcoinSectionTitle = "Bitcoin"
const val btcLabel = "BTC"
fun btcToCurrency(code: String) = "BTC to $code"
const val cancel = "Cancel"
const val clearSearch = "Clear search"
const val currenciesSectionTitle = "Currencies"
fun currenciesSelectedCount(count: Int) = "$count selected"
const val currentCurrencySectionTitle = "Current Currency"
fun currencyNotPriced(sourceName: String) = "Not priced by $sourceName"
fun currencyOptions(code: String) = "Options for $code"
fun currencyOptionLabel(code: String, displayName: String) = "$code - $displayName"
fun currencyToSats(code: String) = "$code to Sats"
const val done = "Done"
fun exceedsMaxSupply(maxSupplyText: String) = "Exceeds the $maxSupplyText maximum supply"
const val loadingRatesStatus = "Loading rates…"
const val moveDown = "Move down"
const val moveToBottom = "Move to bottom"
const val moveToTop = "Move to top"
const val moveUp = "Move up"
const val priceSource = "Price Source"
const val pricedCurrenciesSectionTitle = "Priced Currencies"
const val refresh = "Refresh"
const val remove = "Remove"
const val resetButton = "Reset"
fun resetConfirmationMessage(code: String) =
"This keeps $code, but removes every other currency you've added."
const val resetConfirmationTitle = "Remove all selected currencies?"
const val retry = "Retry"
const val satsLabel = "Sats"
const val searchCurrenciesPlaceholder = "Search currencies"
const val selectedCurrenciesSectionTitle = "Selected Currencies"
const val unpricedCurrenciesSectionTitle = "Unpriced Currencies"
fun updatedStatus(time: String) = "Updated $time"
}
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SatsPrice (DOM)</title>
<title>SatsPrice</title>
<meta name="description" content="SatsPrice converts Bitcoin, satoshis, and fiat currency in real time — Compose HTML build.">
<link type="text/css" rel="stylesheet" href="styles.css">
<link rel="icon" href="favicon.ico" sizes="any">