diff --git a/cli/src/jvmMain/kotlin/Cli.kt b/cli/src/jvmMain/kotlin/Cli.kt index 1a44364..f3dbe0b 100644 --- a/cli/src/jvmMain/kotlin/Cli.kt +++ b/cli/src/jvmMain/kotlin/Cli.kt @@ -15,6 +15,12 @@ import com.github.ajalt.clikt.parameters.options.option import com.github.ajalt.clikt.parameters.options.versionOption import java.io.File import java.io.InputStream +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.StandardCharsets import java.util.jar.JarFile val ANDROID = "android" @@ -33,6 +39,69 @@ private fun normalizeTargets(targets: Set): LinkedHashSet = link addAll(targets) } +private val docsHttpClient: HttpClient = HttpClient.newHttpClient() + +internal fun docsBaseUrl(): String = ( + System.getenv("COMPOSABLES_DOCS_BASE_URL") + ?: System.getProperty("composables.docs.baseUrl") + ?: "https://composables.com" + ).trimEnd('/') + +internal fun docsApiUrl( + endpoint: String, + queryParameters: Map = emptyMap(), +): String { + val normalizedEndpoint = endpoint.trim().trimStart('/') + val query = queryParameters.entries.joinToString("&") { (key, value) -> + "${urlEncode(key)}=${urlEncode(value)}" + } + return buildString { + append(docsBaseUrl()) + append("/api/composables-ui-docs/") + append(normalizedEndpoint) + if (query.isNotEmpty()) { + append('?') + append(query) + } + } +} + +internal fun docsMarkdownUrl(slug: String): String = buildString { + append(docsBaseUrl()) + append("/ui/docs/") + append(urlEncode(slug.trim())) + append(".md") +} + +private fun fetchUrl(url: String): String { + val request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Accept", "application/json") + .GET() + .build() + + val response = try { + docsHttpClient.send(request, HttpResponse.BodyHandlers.ofString()) + } catch (error: Exception) { + throw UsageError("Failed to reach Composables UI docs at $url: ${error.message}") + } + + if (response.statusCode() !in 200..299) { + val body = response.body().trim() + val detail = if (body.isEmpty()) "No response body returned." else body + throw UsageError("Docs request failed (${response.statusCode()}) for $url\n$detail") + } + + return response.body() +} + +private fun fetchDocsApi( + endpoint: String, + queryParameters: Map = emptyMap(), +): String = fetchUrl(docsApiUrl(endpoint, queryParameters)) + +private fun urlEncode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8) + private data class ProjectInstruction( val label: String, val detail: String? = null, @@ -122,7 +191,7 @@ private fun buildProjectReadme( suspend fun main(args: Array) { ComposablesCli() - .subcommands(Init(), Add().subcommands(AddModule()), Target()) + .subcommands(Init(), Add().subcommands(AddModule()), Docs().subcommands(DocsList(), DocsSearch(), DocsGet()), Target()) .main(args) } @@ -235,6 +304,49 @@ class Add : CliktCommand("add") { } } +class Docs : CliktCommand("docs") { + override fun help(context: Context): String = """ + Reads Composables UI documentation in a machine-friendly format. + """.trimIndent() + + override fun run() { + } +} + +class DocsList : CliktCommand("list") { + override fun help(context: Context): String = """ + Lists all Composables UI documentation pages. + """.trimIndent() + + override fun run() { + echo(fetchDocsApi("list")) + } +} + +class DocsSearch : CliktCommand("search") { + override fun help(context: Context): String = """ + Searches Composables UI documentation pages. + """.trimIndent() + + private val query by argument("query", help = "Search query") + + override fun run() { + echo(fetchDocsApi("search", mapOf("q" to query))) + } +} + +class DocsGet : CliktCommand("get") { + override fun help(context: Context): String = """ + Gets one Composables UI documentation page by slug. + """.trimIndent() + + private val slug by argument("slug", help = "Documentation page slug") + + override fun run() { + echo(fetchUrl(docsMarkdownUrl(slug))) + } +} + class AddModule : CliktCommand("module") { override fun help(context: Context): String = """ Adds a new Compose app module group to the current Gradle project. diff --git a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt index 2198cfa..991e77e 100644 --- a/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt +++ b/cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt @@ -338,6 +338,32 @@ class CliTest { } } + @Test + fun `docsApiUrl uses the public Composables docs endpoint by default`() { + withDocsBaseUrl(null) { + assertThat(docsApiUrl("list")).isEqualTo("https://composables.com/api/composables-ui-docs/list") + } + } + + @Test + fun `docsApiUrl respects a custom base url and encodes query parameters`() { + withDocsBaseUrl("http://127.0.0.1:8080/") { + assertThat( + docsApiUrl( + endpoint = "search", + queryParameters = mapOf("q" to "dropdown menu"), + ), + ).isEqualTo("http://127.0.0.1:8080/api/composables-ui-docs/search?q=dropdown+menu") + } + } + + @Test + fun `docsMarkdownUrl points at the resolved ui docs markdown page`() { + withDocsBaseUrl("http://127.0.0.1:8080/") { + assertThat(docsMarkdownUrl("buttons")).isEqualTo("http://127.0.0.1:8080/ui/docs/buttons.md") + } + } + private fun withTempDir(block: (File) -> Unit) { val dir = Files.createTempDirectory("composables-cli-test").toFile() try { @@ -357,6 +383,24 @@ class CliTest { } } + private fun withDocsBaseUrl(value: String?, block: () -> Unit) { + val original = System.getProperty("composables.docs.baseUrl") + try { + if (value == null) { + System.clearProperty("composables.docs.baseUrl") + } else { + System.setProperty("composables.docs.baseUrl", value) + } + block() + } finally { + if (original == null) { + System.clearProperty("composables.docs.baseUrl") + } else { + System.setProperty("composables.docs.baseUrl", original) + } + } + } + private fun hasAndroidTarget(buildFile: File): Boolean { val method = Target::class.java.getDeclaredMethod("hasAndroidTarget", File::class.java) method.isAccessible = true