From 218b333fd162ece2a7f06137dc09f39a8a038fa0 Mon Sep 17 00:00:00 2001 From: 0xnm <0xnm@users.noreply.github.com> Date: Sun, 13 Oct 2024 17:26:47 +0200 Subject: [PATCH 1/2] Add shared JVM/JS Kotlin code example --- .../web/5-webapp-kotlinjs-shared/build.mill | 118 ++++++ .../client/src/ClientApp.kt | 89 +++++ .../resources/logback.xml | 11 + .../resources/webapp/index.css | 378 ++++++++++++++++++ .../shared/src/Shared.kt | 77 ++++ .../5-webapp-kotlinjs-shared/src/WebApp.kt | 125 ++++++ .../test/src/WebAppTests.kt | 28 ++ .../mill/kotlinlib/PlatformKotlinModule.scala | 37 ++ 8 files changed, 863 insertions(+) create mode 100644 example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill create mode 100644 example/kotlinlib/web/5-webapp-kotlinjs-shared/client/src/ClientApp.kt create mode 100644 example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/logback.xml create mode 100644 example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/webapp/index.css create mode 100644 example/kotlinlib/web/5-webapp-kotlinjs-shared/shared/src/Shared.kt create mode 100644 example/kotlinlib/web/5-webapp-kotlinjs-shared/src/WebApp.kt create mode 100644 example/kotlinlib/web/5-webapp-kotlinjs-shared/test/src/WebAppTests.kt create mode 100644 kotlinlib/src/mill/kotlinlib/PlatformKotlinModule.scala diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill b/example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill new file mode 100644 index 00000000000..e7ca7bfefdb --- /dev/null +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill @@ -0,0 +1,118 @@ +package build +import mill._, kotlinlib._, kotlinlib.js._ + +trait AppKotlinModule extends KotlinModule { + def kotlinVersion = "1.9.25" +} + +trait AppKotlinJSModule extends AppKotlinModule with KotlinJSModule + +object `package` extends RootModule with AppKotlinModule { + + def ktorVersion = "2.3.12" + def kotlinHtmlVersion = "0.11.0" + def kotlinxSerializationVersion = "1.6.3" + + def mainClass = Some("webapp.WebApp") + + def moduleDeps = Seq(shared.jvm) + + def ivyDeps = Agg( + ivy"io.ktor:ktor-server-core-jvm:$ktorVersion", + ivy"io.ktor:ktor-server-netty-jvm:$ktorVersion", + ivy"io.ktor:ktor-server-html-builder-jvm:$ktorVersion", + ivy"io.ktor:ktor-server-content-negotiation-jvm:$ktorVersion", + ivy"io.ktor:ktor-serialization-kotlinx-json-jvm:$ktorVersion", + ivy"ch.qos.logback:logback-classic:1.5.8", + ) + + def resources = Task { + os.makeDir(Task.dest / "webapp") + val jsPath = client.linkBinary().classes.path + os.copy(jsPath / "client.js", Task.dest / "webapp/client.js") + os.copy(jsPath / "client.js.map", Task.dest / "webapp/client.js.map") + super.resources() ++ Seq(PathRef(Task.dest)) + } + + object test extends KotlinTests with TestModule.Junit5 { + def ivyDeps = super.ivyDeps() ++ Agg( + ivy"io.kotest:kotest-runner-junit5-jvm:5.9.1", + ivy"io.ktor:ktor-server-test-host-jvm:$ktorVersion" + ) + } + + object shared extends Module { + + trait SharedModule extends AppKotlinModule with PlatformKotlinModule { + def processors = Task { + defaultResolver().resolveDeps( + Agg( + ivy"org.jetbrains.kotlin:kotlin-serialization-compiler-plugin:${kotlinVersion()}" + ) + ) + } + + def kotlincOptions = super.kotlincOptions() ++ Seq( + s"-Xplugin=${processors().head.path}" + ) + } + + object jvm extends SharedModule { + def ivyDeps = super.ivyDeps() ++ Agg( + ivy"org.jetbrains.kotlinx:kotlinx-html-jvm:$kotlinHtmlVersion", + ivy"org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:$kotlinxSerializationVersion", + ) + } + object js extends SharedModule with AppKotlinJSModule { + def ivyDeps = super.ivyDeps() ++ Agg( + ivy"org.jetbrains.kotlinx:kotlinx-html-js:$kotlinHtmlVersion", + ivy"org.jetbrains.kotlinx:kotlinx-serialization-json-js:$kotlinxSerializationVersion", + ) + } + } + + object client extends AppKotlinJSModule { + def splitPerModule = false + def moduleDeps = Seq(shared.js) + def ivyDeps = Agg( + ivy"org.jetbrains.kotlinx:kotlinx-html-js:$kotlinHtmlVersion", + ivy"org.jetbrains.kotlinx:kotlinx-serialization-json-js:$kotlinxSerializationVersion", + ) + } +} + +// A Kotlin/JVM backend server wired up with a Kotlin/JS front-end, with a +// `shared` module containing code that is used in both client and server. +// Rather than the server sending HTML for the initial page load and HTML for +// page updates, it sends HTML for the initial load and JSON for page updates +// which is then rendered into HTML on the client. +// +// The JSON serialization logic and HTML generation logic in the `shared` module +// is shared between client and server, and uses libraries like `kotlinx-serialization` and +// `kotlinx-html` which work on both Kotlin/JVM and Kotlin/JS. This allows us to freely +// move code between the client and server, without worrying about what +// platform or language the code was originally implemented in. +// +// This is a minimal example of shared code compiled to Kotlin/JVM and Kotlin/JS, +// running on both client and server, meant for illustrating the build +// configuration. A full exploration of client-server code sharing techniques +// is beyond the scope of this example. + +/** Usage + +> ./mill test +...webapp.WebAppTestssimpleRequest ... + +> ./mill runBackground + +> curl http://localhost:8083 +...What needs to be done... +... + +> curl http://localhost:8083/static/client.js +...kotlin.js... +... + +> ./mill clean runBackground + +*/ diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/client/src/ClientApp.kt b/example/kotlinlib/web/5-webapp-kotlinjs-shared/client/src/ClientApp.kt new file mode 100644 index 00000000000..22aaadea22b --- /dev/null +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/client/src/ClientApp.kt @@ -0,0 +1,89 @@ +package client + +import kotlinx.browser.document +import kotlinx.browser.window +import kotlinx.html.div +import kotlinx.html.stream.createHTML +import kotlinx.serialization.* +import kotlinx.serialization.json.* +import org.w3c.dom.Element +import org.w3c.dom.HTMLInputElement +import org.w3c.dom.asList +import org.w3c.dom.events.KeyboardEvent +import org.w3c.dom.get +import org.w3c.fetch.RequestInit +import shared.* + +object ClientApp { + + private var state = "all" + + private val todoApp: Element + get() = checkNotNull(document.getElementsByClassName("todoApp")[0]) + + private fun postFetchUpdate(url: String) { + window + .fetch(url, RequestInit(method = "POST")) + .then { it.text() } + .then { text -> + todoApp.innerHTML = createHTML().div { + renderBody(Json.decodeFromString>(text), state) + } + initListeners() + } + } + + private fun bindEvent(cls: String, url: String, endState: String? = null) { + document.getElementsByClassName(cls)[0] + ?.addEventListener("mousedown", { + postFetchUpdate(url) + if (endState != null) state = endState + } + ) + } + + private fun bindIndexedEvent(cls: String, block: (String) -> String) { + for (elem in document.getElementsByClassName(cls).asList()) { + elem.addEventListener( + "mousedown", + { postFetchUpdate(block(elem.getAttribute("data-todo-index")!!)) } + ) + } + } + + fun initListeners() { + bindIndexedEvent("destroy") { + "/delete/$state/$it" + } + bindIndexedEvent("toggle") { + "/toggle/$state/$it" + } + bindEvent("toggle-all", "/toggle-all/$state") + bindEvent("todo-all", "/list/all", "all") + bindEvent("todo-active", "/list/active", "active") + bindEvent("todo-completed", "/list/completed", "completed") + bindEvent("clear-completed", "/clear-completed/$state") + + val newTodoInput = document.getElementsByClassName("new-todo")[0] as HTMLInputElement + newTodoInput.addEventListener( + "keydown", + { + check(it is KeyboardEvent) + if (it.keyCode == 13) { + window + .fetch("/add/$state", RequestInit(method = "POST", body = newTodoInput.value)) + .then { it.text() } + .then { text -> + newTodoInput.value = "" + todoApp.innerHTML = createHTML().div { + renderBody(Json.decodeFromString>(text), state) + } + initListeners() + } + } + } + ) + } +} + +fun main(args: Array) = ClientApp.initListeners() diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/logback.xml b/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/logback.xml new file mode 100644 index 00000000000..d330b77b822 --- /dev/null +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{YYYY-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/webapp/index.css b/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/webapp/index.css new file mode 100644 index 00000000000..07ef4a160e3 --- /dev/null +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/webapp/index.css @@ -0,0 +1,378 @@ +html, +body { + margin: 0; + padding: 0; +} + +button { + margin: 0; + padding: 0; + border: 0; + background: none; + font-size: 100%; + vertical-align: baseline; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +body { + font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; + line-height: 1.4em; + background: #f5f5f5; + color: #4d4d4d; + min-width: 230px; + max-width: 550px; + margin: 0 auto; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; + font-weight: 300; +} + +button, +input[type="checkbox"] { + outline: none; +} + +.hidden { + display: none; +} + +.todoapp { + background: #fff; + margin: 130px 0 40px 0; + position: relative; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); +} + +.todoapp input::-webkit-input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::-moz-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp input::input-placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; +} + +.todoapp h1 { + position: absolute; + top: -155px; + width: 100%; + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; +} + +.new-todo, +.edit { + position: relative; + margin: 0; + width: 100%; + font-size: 24px; + font-family: inherit; + font-weight: inherit; + line-height: 1.4em; + border: 0; + outline: none; + color: inherit; + padding: 6px; + border: 1px solid #999; + box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-font-smoothing: antialiased; + font-smoothing: antialiased; +} + +.new-todo { + padding: 16px 16px 16px 60px; + border: none; + background: rgba(0, 0, 0, 0.003); + box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); +} + +.main { + position: relative; + z-index: 2; + border-top: 1px solid #e6e6e6; +} + +label[for='toggle-all'] { + display: none; +} + +.toggle-all { + position: absolute; + top: -55px; + left: -12px; + width: 60px; + height: 34px; + text-align: center; + border: none; /* Mobile Safari */ +} + +.toggle-all:before { + content: '❯'; + font-size: 22px; + color: #e6e6e6; + padding: 10px 27px 10px 27px; +} + +.toggle-all:checked:before { + color: #737373; +} + +.todo-list { + margin: 0; + padding: 0; + list-style: none; +} + +.todo-list li { + position: relative; + font-size: 24px; + border-bottom: 1px solid #ededed; +} + +.todo-list li:last-child { + border-bottom: none; +} + +.todo-list li.editing { + border-bottom: none; + padding: 0; +} + +.todo-list li.editing .edit { + display: block; + width: 506px; + padding: 13px 17px 12px 17px; + margin: 0 0 0 43px; +} + +.todo-list li.editing .view { + display: none; +} + +.todo-list li .toggle { + text-align: center; + width: 40px; + /* auto, since non-WebKit browsers doesn't support input styling */ + height: auto; + position: absolute; + top: 0; + bottom: 0; + margin: auto 0; + border: none; /* Mobile Safari */ + -webkit-appearance: none; + appearance: none; +} + +.todo-list li .toggle:after { + content: url('data:image/svg+xml;utf8,'); +} + +.todo-list li .toggle:checked:after { + content: url('data:image/svg+xml;utf8,'); +} + +.todo-list li label { + white-space: pre-line; + word-break: break-all; + padding: 15px 60px 15px 15px; + margin-left: 45px; + display: block; + line-height: 1.2; + transition: color 0.4s; +} + +.todo-list li.completed label { + color: #d9d9d9; + text-decoration: line-through; +} + +.todo-list li .destroy { + display: none; + position: absolute; + top: 0; + right: 10px; + bottom: 0; + width: 40px; + height: 40px; + margin: auto 0; + font-size: 30px; + color: #cc9a9a; + margin-bottom: 11px; + transition: color 0.2s ease-out; +} + +.todo-list li .destroy:hover { + color: #af5b5e; +} + +.todo-list li .destroy:after { + content: '×'; +} + +.todo-list li:hover .destroy { + display: block; +} + +.todo-list li .edit { + display: none; +} + +.todo-list li.editing:last-child { + margin-bottom: -1px; +} + +.footer { + color: #777; + padding: 10px 15px; + height: 20px; + text-align: center; + border-top: 1px solid #e6e6e6; +} + +.footer:before { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 50px; + overflow: hidden; + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); +} + +.todo-count { + float: left; + text-align: left; +} + +.todo-count strong { + font-weight: 300; +} + +.filters { + margin: 0; + padding: 0; + list-style: none; + position: absolute; + right: 0; + left: 0; +} + +.filters li { + display: inline; +} + +.filters li a { + color: inherit; + margin: 3px; + padding: 3px 7px; + text-decoration: none; + border: 1px solid transparent; + border-radius: 3px; +} + +.filters li a.selected, +.filters li a:hover { + border-color: rgba(175, 47, 47, 0.1); +} + +.filters li a.selected { + border-color: rgba(175, 47, 47, 0.2); +} + +.clear-completed, +html .clear-completed:active { + float: right; + position: relative; + line-height: 20px; + text-decoration: none; + cursor: pointer; + position: relative; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin: 65px auto 0; + color: #bfbfbf; + font-size: 10px; + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); + text-align: center; +} + +.info p { + line-height: 1; +} + +.info a { + color: inherit; + text-decoration: none; + font-weight: 400; +} + +.info a:hover { + text-decoration: underline; +} + +/* + Hack to remove background from Mobile Safari. + Can't use it globally since it destroys checkboxes in Firefox +*/ +@media screen and (-webkit-min-device-pixel-ratio:0) { + .toggle-all, + .todo-list li .toggle { + background: none; + } + + .todo-list li .toggle { + height: 40px; + } + + .toggle-all { + -webkit-transform: rotate(90deg); + transform: rotate(90deg); + -webkit-appearance: none; + appearance: none; + } +} + +@media (max-width: 430px) { + .footer { + height: 50px; + } + + .filters { + bottom: 10px; + } +} diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/shared/src/Shared.kt b/example/kotlinlib/web/5-webapp-kotlinjs-shared/shared/src/Shared.kt new file mode 100644 index 00000000000..2ec694831c4 --- /dev/null +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/shared/src/Shared.kt @@ -0,0 +1,77 @@ +package shared + +import kotlinx.html.* +import kotlinx.html.stream.createHTML +import kotlinx.serialization.Serializable + +@Serializable +data class Todo(val checked: Boolean, val text: String) + +fun FlowContent.renderBody(todos: List, state: String) { + val filteredTodos = when (state) { + "all" -> todos.withIndex() + "active" -> todos.withIndex().filter { !it.value.checked } + "completed" -> todos.withIndex().filter { it.value.checked } + else -> throw IllegalStateException("Unknown state=$state") + } + div { + header(classes = "header") { + h1("todos") + input(classes = "new-todo") { + placeholder = "What needs to be done?" + } + } + section(classes = "main") { + input( + classes = "toggle-all", + type = InputType.checkBox + ) { + id = "toggle-all" + checked = todos.any { it.checked } + } + label { + htmlFor = "toggle-all" + +"Mark all as complete" + } + ul(classes = "todo-list") { + filteredTodos.forEach { (index, todo) -> + li(classes = if (todo.checked) "completed" else "") { + div(classes = "view") { + input(classes = "toggle", type = InputType.checkBox) { + checked = todo.checked + attributes["data-todo-index"] = index.toString() + } + label { +todo.text } + button(classes = "destroy") { + attributes["data-todo-index"] = index.toString() + } + } + input(classes = "edit") { + value = todo.text + } + } + } + } + } + footer(classes = "footer") { + span(classes = "todo-count") { + strong { + +todos.filter { !it.checked }.size.toString() + } + +" items left" + } + ul(classes = "filters") { + li(classes = "todo-all") { + a(classes = if (state == "all") "selected" else "") { +"All" } + } + li(classes = "todo-active") { + a(classes = if (state == "active") "selected" else "") { +"Active" } + } + li(classes = "todo-completed") { + a(classes = if (state == "completed") "selected" else "") { +"Completed" } + } + } + button(classes = "clear-completed") { +"Clear completed" } + } + } +} diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/src/WebApp.kt b/example/kotlinlib/web/5-webapp-kotlinjs-shared/src/WebApp.kt new file mode 100644 index 00000000000..35434aed503 --- /dev/null +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/src/WebApp.kt @@ -0,0 +1,125 @@ +package webapp + +import io.ktor.serialization.kotlinx.json.* +import io.ktor.server.application.* +import io.ktor.server.engine.* +import io.ktor.server.html.* +import io.ktor.server.http.content.* +import io.ktor.server.netty.* +import io.ktor.server.plugins.contentnegotiation.* +import io.ktor.server.request.* +import io.ktor.server.response.* +import io.ktor.server.routing.* +import io.ktor.server.util.* +import kotlinx.html.* +import shared.* + +object WebApp { + + private val todos = mutableListOf( + Todo(true, "Get started with Cask"), + Todo(false, "Profit!") + ) + + fun add(state: String, text: String) { + todos.add(Todo(false, text)) + } + + fun delete(state: String, index: Int) { + todos.removeAt(index) + } + + fun toggle(state: String, index: Int) { + todos[index] = todos[index].let { + it.copy(checked = !it.checked) + } + } + + fun clearCompleted(state: String) { + todos.removeAll { it.checked } + } + + fun toggleAll(state: String) { + val next = todos.any { it.checked } + for (item in todos.withIndex()) { + todos[item.index] = item.value.copy(checked = next) + } + } + + private fun HTML.renderIndex() { + head { + meta(charset = "utf-8") + meta(name = "viewport", content = "width=device-width, initial-scale=1") + title("Template • TodoMVC") + link(rel = "stylesheet", href = "/static/index.css") + } + body { + section(classes = "todoApp") { + renderBody(todos, "all") + } + footer(classes = "info") { + p { +"Double-click to edit a todo" } + p { + +"Created by " + a(href = "http://todomvc.com") { +"Li Haoyi" } + } + p { + +"Part of " + a(href = "http://todomvc.com") { +"TodoMVC" } + } + } + script(src = "/static/client.js", block = {}) + } + } + + fun configureRoutes(app: Application) { + with(app) { + routing { + get("/") { + call.respondHtml { + renderIndex() + } + } + post("/toggle-all/{state}") { + toggleAll(call.parameters.getOrFail("state")) + call.respond(todos) + } + post("/clear-completed/{state}") { + clearCompleted(call.parameters.getOrFail("state")) + call.respond(todos) + } + post("/toggle/{state}/{index}") { + call.parameters.run { + toggle(getOrFail("state"), getOrFail("index")) + call.respond(todos) + } + } + post("/delete/{state}/{index}") { + call.parameters.run { + delete(getOrFail("state"), getOrFail("index")) + call.respond(todos) + } + } + post("/add/{state}") { + val requestText = call.receiveText() + add(call.parameters.getOrFail("state"), requestText) + call.respond(todos) + } + post("/list/{state}") { + call.respond(todos) + } + staticResources("/static", "webapp") + } + } + } + + @JvmStatic + fun main(args: Array) { + embeddedServer(Netty, port = 8083, host = "0.0.0.0") { + install(ContentNegotiation) { + json() + } + configureRoutes(this) + }.start(wait = true) + } +} diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/test/src/WebAppTests.kt b/example/kotlinlib/web/5-webapp-kotlinjs-shared/test/src/WebAppTests.kt new file mode 100644 index 00000000000..0786f28c639 --- /dev/null +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/test/src/WebAppTests.kt @@ -0,0 +1,28 @@ +package webapp + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.ktor.client.HttpClient +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpStatusCode +import io.ktor.server.testing.testApplication + +class WebAppTests : FunSpec({ + + suspend fun withServer(f: suspend HttpClient.() -> Unit) { + testApplication { + application { WebApp.configureRoutes(this) } + client.use { client -> f(client) } + } + } + + test("simpleRequest") { + withServer { + val response = get("/") + response.status shouldBe HttpStatusCode.OK + response.bodyAsText() shouldContain "What needs to be done?" + } + } +}) diff --git a/kotlinlib/src/mill/kotlinlib/PlatformKotlinModule.scala b/kotlinlib/src/mill/kotlinlib/PlatformKotlinModule.scala new file mode 100644 index 00000000000..23ff797e7ad --- /dev/null +++ b/kotlinlib/src/mill/kotlinlib/PlatformKotlinModule.scala @@ -0,0 +1,37 @@ +package mill.kotlinlib + +import mill._ +import os.Path + +/** + * A [[KotlinModule]] intended for defining `.jvm`/`.js`/etc. submodules + * It supports additional source directories per platform, e.g. `src-jvm/` or + * `src-js/`. + * + * Adjusts the [[millSourcePath]] and [[artifactNameParts]] to ignore the last + * path segment, which is assumed to be the name of the platform the module is + * built against and not something that should affect the filesystem path or + * artifact name + */ +trait PlatformKotlinModule extends KotlinModule { + override def millSourcePath: Path = super.millSourcePath / os.up + + /** + * The platform suffix of this [[PlatformKotlinModule]]. Useful if you want to + * further customize the source paths or artifact names. + */ + def platformKotlinSuffix: String = millModuleSegments + .value + .collect { case l: mill.define.Segment.Label => l.value } + .last + + override def sources: T[Seq[PathRef]] = Task.Sources { + super.sources().flatMap { source => + val platformPath = + PathRef(source.path / _root_.os.up / s"${source.path.last}-$platformKotlinSuffix") + Seq(source, platformPath) + } + } + + override def artifactNameParts: T[Seq[String]] = super.artifactNameParts().dropRight(1) +} From 3709a9407005c28136337949eb59292994d3def2 Mon Sep 17 00:00:00 2001 From: Li Haoyi Date: Mon, 14 Oct 2024 12:35:45 +0800 Subject: [PATCH 2/2] fixes --- .../web/5-webapp-kotlinjs-shared/build.mill | 4 +- .../client/src/ClientApp.kt | 6 +- .../resources/webapp/index.css | 149 ++++++++++-------- .../shared/src/Shared.kt | 2 +- .../5-webapp-kotlinjs-shared/src/WebApp.kt | 6 +- 5 files changed, 91 insertions(+), 76 deletions(-) diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill b/example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill index e7ca7bfefdb..22888ec6264 100644 --- a/example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/build.mill @@ -105,11 +105,11 @@ object `package` extends RootModule with AppKotlinModule { > ./mill runBackground -> curl http://localhost:8083 +> curl http://localhost:8093 ...What needs to be done... ... -> curl http://localhost:8083/static/client.js +> curl http://localhost:8093/static/client.js ...kotlin.js... ... diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/client/src/ClientApp.kt b/example/kotlinlib/web/5-webapp-kotlinjs-shared/client/src/ClientApp.kt index 22aaadea22b..ff590b30e7e 100644 --- a/example/kotlinlib/web/5-webapp-kotlinjs-shared/client/src/ClientApp.kt +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/client/src/ClientApp.kt @@ -19,7 +19,7 @@ object ClientApp { private var state = "all" private val todoApp: Element - get() = checkNotNull(document.getElementsByClassName("todoApp")[0]) + get() = checkNotNull(document.getElementsByClassName("todoapp")[0]) private fun postFetchUpdate(url: String) { window @@ -35,7 +35,7 @@ object ClientApp { private fun bindEvent(cls: String, url: String, endState: String? = null) { document.getElementsByClassName(cls)[0] - ?.addEventListener("mousedown", { + ?.addEventListener("click", { postFetchUpdate(url) if (endState != null) state = endState } @@ -45,7 +45,7 @@ object ClientApp { private fun bindIndexedEvent(cls: String, block: (String) -> String) { for (elem in document.getElementsByClassName(cls).asList()) { elem.addEventListener( - "mousedown", + "click", { postFetchUpdate(block(elem.getAttribute("data-todo-index")!!)) } ) } diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/webapp/index.css b/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/webapp/index.css index 07ef4a160e3..f731c2205d3 100644 --- a/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/webapp/index.css +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/resources/webapp/index.css @@ -1,3 +1,5 @@ +@charset 'utf-8'; + html, body { margin: 0; @@ -17,29 +19,22 @@ button { -webkit-appearance: none; appearance: none; -webkit-font-smoothing: antialiased; - -moz-font-smoothing: antialiased; - font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } body { font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif; line-height: 1.4em; background: #f5f5f5; - color: #4d4d4d; + color: #111111; min-width: 230px; max-width: 550px; margin: 0 auto; -webkit-font-smoothing: antialiased; - -moz-font-smoothing: antialiased; - font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; font-weight: 300; } -button, -input[type="checkbox"] { - outline: none; -} - .hidden { display: none; } @@ -54,30 +49,30 @@ input[type="checkbox"] { .todoapp input::-webkit-input-placeholder { font-style: italic; - font-weight: 300; - color: #e6e6e6; + font-weight: 400; + color: rgba(0, 0, 0, 0.4); } .todoapp input::-moz-placeholder { font-style: italic; - font-weight: 300; - color: #e6e6e6; + font-weight: 400; + color: rgba(0, 0, 0, 0.4); } .todoapp input::input-placeholder { font-style: italic; - font-weight: 300; - color: #e6e6e6; + font-weight: 400; + color: rgba(0, 0, 0, 0.4); } .todoapp h1 { position: absolute; - top: -155px; + top: -140px; width: 100%; - font-size: 100px; - font-weight: 100; + font-size: 80px; + font-weight: 200; text-align: center; - color: rgba(175, 47, 47, 0.15); + color: #b83f45; -webkit-text-rendering: optimizeLegibility; -moz-text-rendering: optimizeLegibility; text-rendering: optimizeLegibility; @@ -92,20 +87,18 @@ input[type="checkbox"] { font-family: inherit; font-weight: inherit; line-height: 1.4em; - border: 0; - outline: none; color: inherit; padding: 6px; border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); box-sizing: border-box; -webkit-font-smoothing: antialiased; - -moz-font-smoothing: antialiased; - font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } .new-todo { padding: 16px 16px 16px 60px; + height: 65px; border: none; background: rgba(0, 0, 0, 0.003); box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03); @@ -117,29 +110,40 @@ input[type="checkbox"] { border-top: 1px solid #e6e6e6; } -label[for='toggle-all'] { - display: none; +.toggle-all { + width: 1px; + height: 1px; + border: none; /* Mobile Safari */ + opacity: 0; + position: absolute; + right: 100%; + bottom: 100%; } -.toggle-all { +.toggle-all + label { + display: flex; + align-items: center; + justify-content: center; + width: 45px; + height: 65px; + font-size: 0; position: absolute; - top: -55px; - left: -12px; - width: 60px; - height: 34px; - text-align: center; - border: none; /* Mobile Safari */ + top: -65px; + left: -0; } -.toggle-all:before { +.toggle-all + label:before { content: '❯'; + display: inline-block; font-size: 22px; - color: #e6e6e6; + color: #949494; padding: 10px 27px 10px 27px; + -webkit-transform: rotate(90deg); + transform: rotate(90deg); } -.toggle-all:checked:before { - color: #737373; +.toggle-all:checked + label:before { + color: #484848; } .todo-list { @@ -165,8 +169,8 @@ label[for='toggle-all'] { .todo-list li.editing .edit { display: block; - width: 506px; - padding: 13px 17px 12px 17px; + width: calc(100% - 43px); + padding: 12px 16px; margin: 0 0 0 43px; } @@ -188,26 +192,36 @@ label[for='toggle-all'] { appearance: none; } -.todo-list li .toggle:after { - content: url('data:image/svg+xml;utf8,'); +.todo-list li .toggle { + opacity: 0; +} + +.todo-list li .toggle + label { + /* + Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433 + IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/ + */ + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-repeat: no-repeat; + background-position: center left; } -.todo-list li .toggle:checked:after { - content: url('data:image/svg+xml;utf8,'); +.todo-list li .toggle:checked + label { + background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E'); } .todo-list li label { - white-space: pre-line; - word-break: break-all; - padding: 15px 60px 15px 15px; - margin-left: 45px; + overflow-wrap: break-word; + padding: 15px 15px 15px 60px; display: block; line-height: 1.2; transition: color 0.4s; + font-weight: 400; + color: #484848; } .todo-list li.completed label { - color: #d9d9d9; + color: #949494; text-decoration: line-through; } @@ -221,17 +235,20 @@ label[for='toggle-all'] { height: 40px; margin: auto 0; font-size: 30px; - color: #cc9a9a; - margin-bottom: 11px; + color: #949494; transition: color 0.2s ease-out; } -.todo-list li .destroy:hover { - color: #af5b5e; +.todo-list li .destroy:hover, +.todo-list li .destroy:focus { + color: #C18585; } .todo-list li .destroy:after { content: '×'; + display: block; + height: 100%; + line-height: 1.1; } .todo-list li:hover .destroy { @@ -247,10 +264,10 @@ label[for='toggle-all'] { } .footer { - color: #777; padding: 10px 15px; height: 20px; text-align: center; + font-size: 15px; border-top: 1px solid #e6e6e6; } @@ -300,23 +317,21 @@ label[for='toggle-all'] { border-radius: 3px; } -.filters li a.selected, .filters li a:hover { - border-color: rgba(175, 47, 47, 0.1); + border-color: #DB7676; } .filters li a.selected { - border-color: rgba(175, 47, 47, 0.2); + border-color: #CE4646; } .clear-completed, html .clear-completed:active { float: right; position: relative; - line-height: 20px; + line-height: 19px; text-decoration: none; cursor: pointer; - position: relative; } .clear-completed:hover { @@ -325,8 +340,8 @@ html .clear-completed:active { .info { margin: 65px auto 0; - color: #bfbfbf; - font-size: 10px; + color: #4d4d4d; + font-size: 11px; text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5); text-align: center; } @@ -358,13 +373,6 @@ html .clear-completed:active { .todo-list li .toggle { height: 40px; } - - .toggle-all { - -webkit-transform: rotate(90deg); - transform: rotate(90deg); - -webkit-appearance: none; - appearance: none; - } } @media (max-width: 430px) { @@ -376,3 +384,10 @@ html .clear-completed:active { bottom: 10px; } } + +:focus, +.toggle:focus + label, +.toggle-all:focus + label { + box-shadow: 0 0 2px 2px #CF7D7D; + outline: 0; +} \ No newline at end of file diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/shared/src/Shared.kt b/example/kotlinlib/web/5-webapp-kotlinjs-shared/shared/src/Shared.kt index 2ec694831c4..905453ffb49 100644 --- a/example/kotlinlib/web/5-webapp-kotlinjs-shared/shared/src/Shared.kt +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/shared/src/Shared.kt @@ -16,7 +16,7 @@ fun FlowContent.renderBody(todos: List, state: String) { } div { header(classes = "header") { - h1("todos") + h1 { +"todos" } input(classes = "new-todo") { placeholder = "What needs to be done?" } diff --git a/example/kotlinlib/web/5-webapp-kotlinjs-shared/src/WebApp.kt b/example/kotlinlib/web/5-webapp-kotlinjs-shared/src/WebApp.kt index 35434aed503..58e02667e36 100644 --- a/example/kotlinlib/web/5-webapp-kotlinjs-shared/src/WebApp.kt +++ b/example/kotlinlib/web/5-webapp-kotlinjs-shared/src/WebApp.kt @@ -40,7 +40,7 @@ object WebApp { } fun toggleAll(state: String) { - val next = todos.any { it.checked } + val next = todos.any { !it.checked } for (item in todos.withIndex()) { todos[item.index] = item.value.copy(checked = next) } @@ -54,7 +54,7 @@ object WebApp { link(rel = "stylesheet", href = "/static/index.css") } body { - section(classes = "todoApp") { + section(classes = "todoapp") { renderBody(todos, "all") } footer(classes = "info") { @@ -115,7 +115,7 @@ object WebApp { @JvmStatic fun main(args: Array) { - embeddedServer(Netty, port = 8083, host = "0.0.0.0") { + embeddedServer(Netty, port = 8093, host = "0.0.0.0") { install(ContentNegotiation) { json() }