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 // comparison module is js-only for now. kotlin { js { browser() binaries.executable() } sourceSets { commonMain { kotlin.srcDir(generateStrings) dependencies { implementation(project(":shared")) implementation(libs.compose.runtime) implementation(libs.compose.html.core) } } } }