Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 113 additions & 1 deletion cli/src/jvmMain/kotlin/Cli.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -33,6 +39,69 @@ private fun normalizeTargets(targets: Set<String>): LinkedHashSet<String> = 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<String, String> = 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<String, String> = 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,
Expand Down Expand Up @@ -122,7 +191,7 @@ private fun buildProjectReadme(

suspend fun main(args: Array<String>) {
ComposablesCli()
.subcommands(Init(), Add().subcommands(AddModule()), Target())
.subcommands(Init(), Add().subcommands(AddModule()), Docs().subcommands(DocsList(), DocsSearch(), DocsGet()), Target())
.main(args)
}

Expand Down Expand Up @@ -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.
Expand Down
44 changes: 44 additions & 0 deletions cli/src/jvmTest/kotlin/com/composables/cli/CliTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
Loading