Skip to content
Open
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
104 changes: 104 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Valkyrie β€” AI Agent Guide

## Project Overview

Valkyrie converts SVG/XML icons to Compose `ImageVector` Kotlin code. It ships as four tools sharing the same core
pipeline: **IDEA/Android Studio plugin**, **CLI**, **Gradle plugin**, and a **WASM web app** (WIP).

## Architecture: Data Flow

```
SVG / XML file
β”‚
β–Ό
components:parser (jvm/svg, jvm/xml, kmp/xml, kmp/svg)
β”‚ produces ──▢ sdk:ir:core (IrImageVector, IrVectorNode, IrPathNode, …)
β–Ό
components:parser:unified (SvgXmlParser β€” single entry point for both formats)
β”‚
β–Ό
components:generator (kmp/imagevector, jvm/imagevector, iconpack)
β”‚ produces ──▢ Kotlin source string
β–Ό
tools:idea-plugin / tools:cli / tools:gradle-plugin
```

- **`sdk/ir/core`** β€” Intermediate Representation (IR). All domain types live here (`IrImageVector`, `IrVectorNode`,
`IrFill`, `IrStroke`, etc.). Parsers produce IR; generators consume IR.
- **`components/parser/unified`** β€” `SvgXmlParser` is the unified entry point used by all tools.
- **`components/generator/kmp/imagevector`** β€” KMP-compatible generator (used by web/CLI/Gradle); JVM variant (
`jvm/imagevector`) uses KotlinPoet.
- **`sdk/shared`** β€” `ValkyrieMode` enum (`Simple` | `IconPack`) controls generation style across all tools.
- **`build-logic/`** β€” Convention plugins (`valkyrie.jvm`, `valkyrie.kmp`, `valkyrie.abi`, `valkyrie.compose`,
`valkyrie.kover`) applied via `alias(libs.plugins.valkyrie.*)`.

## Module Taxonomy

| Prefix | Purpose |
|----------------|---------------------------------------------------------------------|
| `sdk/*` | Reusable building blocks (IR, Compose UI, IntelliJ PSI, utils) |
| `components/*` | Core pipeline: parsers & generators; have public ABI tracked |
| `tools/*` | End-user deliverables: idea-plugin, cli, gradle-plugin, compose-app |

## Developer Workflows

```bash
# Verify before committing
./gradlew test
./gradlew spotlessCheck
./gradlew checkLegacyAbi # ABI compatibility check for components/*

# Fix formatting
./gradlew spotlessApply

# Update ABI snapshots after intentional API changes
./gradlew updateLegacyAbi

# IDEA plugin
./gradlew buildPlugin # produces tools/idea-plugin/build/distributions/
./gradlew runIde # launches sandbox IDE

# Web / WASM
./gradlew tools:compose-app:wasmJsBrowserDevelopmentRun

# Coverage
./gradlew components:test:coverage:koverLog
./gradlew components:test:coverage:koverHtmlReport
```

**Java 21+ is required** (`settings.gradle.kts` enforces this at configuration time).

## Code Style

- **ktlint** via Spotless on all `src/**/*.kt`. Run `./gradlew spotlessApply` before pushing.
- Compose rules enforced: Material2 disallowed (`compose_disallow_material2 = true`), preview naming required (suffix
strategy), lambda-param-event-trailing disabled.
- KotlinGradle files also linted.

## Convention Plugins (build-logic)

Every module picks exactly one of:

- `alias(libs.plugins.valkyrie.jvm)` β€” JVM-only library
- `alias(libs.plugins.valkyrie.kmp)` β€” Kotlin Multiplatform (JVM + wasmJs targets)
- `alias(libs.plugins.valkyrie.compose)` β€” adds Compose compiler

Plus optional: `valkyrie.abi` (ABI tracking), `valkyrie.kover` (coverage).
Comment thread
egorikftp marked this conversation as resolved.

## Key Files

- `sdk/ir/core/src/commonMain/.../IrImageVector.kt` β€” central domain model
- `components/parser/unified/src/commonMain/.../SvgXmlParser.kt` β€” unified parser entry point
- `components/generator/kmp/imagevector/src/commonMain/.../ImageVectorGenerator.kt` β€” KMP generator
- `sdk/shared/src/commonMain/.../ValkyrieMode.kt` β€” Simple vs IconPack mode
- `gradle/libs.versions.toml` β€” main version catalog; `gradle/cli.versions.toml`, `gradle/gradle.versions.toml`,
`gradle/plugin.versions.toml` for tool-specific versions
- `tools/idea-plugin/CHANGELOG.md` β€” updated via `./gradlew tools:idea-plugin:patchChangelog`

## IDEA Plugin Specifics

- Targets IntelliJ IDEA 2025.3.3 (`sinceBuild = "253.31033.145"`, `untilBuild` is unbounded).
- Bundled Kotlin/Coroutines/Compose are excluded from the plugin ZIP (classpath clash workaround).
- Signing credentials read from env vars: `CERTIFICATE_CHAIN`, `PRIVATE_KEY`, `PRIVATE_KEY_PASSWORD`.
- Publish token: `PUBLISH_TOKEN`.

8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ needs.
- [`changelog` command](#changelog-command)
- [Build](#build-cli)
- 🐘 [Gradle plugin](#gradle-plugin)
- [Compatibility](#compatibility)
- [Plugin configuration](#plugin-configuration)
- [Samples](#gradle-plugin-samples)
- [Basic conversion](#basic-conversion)
Expand Down Expand Up @@ -368,6 +369,13 @@ Run `./gradlew buildCLI` to build minified version of CLI tool. Artifact will be
The Gradle plugin automates the conversion of SVG/XML files to Compose ImageVector format during the build process. It's
ideal for projects that need to version control icon sources and generate type-safe Kotlin code automatically.

### Compatibility

| Valkyrie plugin version | Min AGP | Min Gradle |
|-------------------------|---------|-------------------------|
| 0.4.0 | 9.0.0 | 9.0.0 (should be lower) |
| 0.5.0 | 9.2.0 | 9.4.1 |

### Common scenarios

- **Team collaboration**: Keep SVG/XML sources in version control and let the build system generate Kotlin code for
Expand Down
2 changes: 1 addition & 1 deletion gradle/gradle.versions.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[versions]
gradle-plugin-version = "0.4.0"

agp = "8.13.2"
agp = "9.2.0"
Comment thread
egorikftp marked this conversation as resolved.

[libraries]
agp-api = { module = "com.android.tools.build:gradle-api", version.ref = "agp" }
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ ktor = "3.3.1"

[libraries]
androidx-collection = { module = "androidx.collection:collection", version.ref = "collection" }
android-build-tools = "com.android.tools:sdk-common:31.13.2"
android-build-tools = "com.android.tools:sdk-common:32.2.0"

compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose" }
compose-material3 = "org.jetbrains.compose.material3:material3:1.10.0-alpha05"
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
Expand Down
2 changes: 1 addition & 1 deletion gradlew

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions tools/gradle-plugin/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

## Unreleased

### Added

- Add support for AGP 9.0+ built-in Kotlin β€” source-set enumeration and generated source wiring now work
without the external `kotlin("android")` plugin via the stable `CommonExtension` API

### Removed

- Drop support for AGP 8.x. The minimum supported AGP version is now **9.2.0**.

## 0.4.0 - 2026-02-26

### Added
Expand Down
2 changes: 1 addition & 1 deletion tools/gradle-plugin/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ configurations.named(API_ELEMENTS_CONFIGURATION_NAME) {
attributes.attribute(
// TODO: https://github.com/gradle/gradle/issues/24608
GradlePluginApiVersion.GRADLE_PLUGIN_API_VERSION_ATTRIBUTE,
objects.named("9.0.0"),
objects.named("9.4.1"),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
import org.gradle.declarative.dsl.model.annotations.Configuring

abstract class IconPackExtension @Inject constructor(
private val objects: ObjectFactory,
Expand Down Expand Up @@ -62,7 +61,6 @@ abstract class IconPackExtension @Inject constructor(
.convention(emptyList())

@Suppress("unused")
@Configuring
fun nested(action: NestedPack.() -> Unit) {
val config = objects.newInstance<NestedPack>().apply(action)
nestedPacks.add(config)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import org.gradle.api.model.ObjectFactory
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Nested
import org.gradle.api.tasks.Optional
import org.gradle.declarative.dsl.model.annotations.Configuring

abstract class ValkyrieExtension @Inject constructor(private val objects: ObjectFactory) {
/**
Expand Down Expand Up @@ -81,7 +80,6 @@ abstract class ValkyrieExtension @Inject constructor(private val objects: Object
* Configures code style options for generated code
*/
@Suppress("unused")
@Configuring
fun codeStyle(action: CodeStyleConfigExtension.() -> Unit) = action.invoke(codeStyle)

/**
Expand All @@ -101,14 +99,12 @@ abstract class ValkyrieExtension @Inject constructor(private val objects: Object
* Configures ImageVector generation options
*/
@Suppress("unused")
@Configuring
fun imageVector(action: ImageVectorConfigExtension.() -> Unit) = action.invoke(imageVector)

/**
* Configures Icon Pack options
*/
@Suppress("unused")
@Configuring
fun iconPack(action: IconPackExtension.() -> Unit) {
val spec = iconPack.getOrElse(objects.newInstance<IconPackExtension>()).apply(action)
iconPack.set(spec)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package io.github.composegears.valkyrie.gradle

import com.android.build.api.dsl.CommonExtension
import com.android.build.api.variant.AndroidComponentsExtension
import io.github.composegears.valkyrie.gradle.dsl.create
import io.github.composegears.valkyrie.gradle.dsl.findByType
import io.github.composegears.valkyrie.gradle.dsl.getByType
import io.github.composegears.valkyrie.gradle.dsl.withType
import io.github.composegears.valkyrie.gradle.internal.DEFAULT_GENERATED_SOURCES_DIR
Expand All @@ -9,9 +12,9 @@ import io.github.composegears.valkyrie.gradle.internal.common.ExtensionValidator
import io.github.composegears.valkyrie.gradle.internal.common.PackageNameProvider.packageNameOrThrow
import io.github.composegears.valkyrie.gradle.internal.registerTask
import io.github.composegears.valkyrie.gradle.internal.task.GenerateImageVectorsTask
import io.github.composegears.valkyrie.parser.unified.ext.capitalized
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.jetbrains.kotlin.gradle.dsl.KotlinAndroidProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension
import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension
import org.jetbrains.kotlin.gradle.plugin.KotlinSourceSetContainer
Expand All @@ -34,14 +37,29 @@ class ValkyrieGradlePlugin : Plugin<Project> {
registerTasks<KotlinMultiplatformExtension>(extension)
}

pluginManager.withPlugin("org.jetbrains.kotlin.android") {
pluginManager.withPlugin("com.android.base") {
registerTasks<KotlinAndroidProjectExtension>(extension)
}
// AGP 9.0+ has built-in Kotlin β€” kotlin("android") is no longer applied.
// Use CommonExtension.sourceSets (stable AGP API) instead of KotlinAndroidProjectExtension
// so that source-set enumeration works regardless of whether the external Kotlin plugin is present.
// Only one Android plugin can be applied per module, so each callback fires at most once.
listOf(
"com.android.application",
"com.android.library",
"com.android.test",
"com.android.dynamic-feature",
).forEach { id ->
pluginManager.withPlugin(id) { registerAndroidTasks(extension) }
}

val codegenTasks = tasks.withType<GenerateImageVectorsTask>()

// AGP's ExtractAnnotations tasks scan all source directories contributing to a variant, including
// the generated ones registered via addStaticSourceDirectory. Because addStaticSourceDirectory
// doesn't declare a producer-task relationship, Gradle's strict dependency validation (used with
// --configuration-cache) raises an "implicit dependency" error. Declaring an explicit dependsOn
// here ensures the files are generated before any extractAnnotations task reads them.
tasks.matching { it.name.startsWith("extract") && it.name.endsWith("Annotations") }
.configureEach { it.dependsOn(codegenTasks) }

// Run generation immediately if we're syncing Intellij/Android Studio - helps to speed up dev cycle
afterEvaluate {
ExtensionValidator.validate(extension)
Expand All @@ -53,7 +71,7 @@ class ValkyrieGradlePlugin : Plugin<Project> {
}

// Run generation before any kind of kotlin source processing
tasks.withType(AbstractKotlinCompile::class.java).configureEach { compileTask ->
tasks.withType<AbstractKotlinCompile<*>>().configureEach { compileTask ->
compileTask.dependsOn(codegenTasks)
}

Expand All @@ -68,4 +86,65 @@ class ValkyrieGradlePlugin : Plugin<Project> {
registerTask(project, extension, sourceSet)
}
}

/**
* Registers generation tasks for all Android source sets using the stable [CommonExtension] API.
*
* In AGP 9.0 with built-in Kotlin (no external kotlin("android") plugin):
* - Phase 1: enumerate source sets via [CommonExtension.sourceSets] and register one
* [GenerateImageVectorsTask] per source set with its output directory set via convention.
* - Phase 2: use [AndroidComponentsExtension.onVariants] to wire each variant's generated
* source directory via [com.android.build.api.variant.SourceDirectories.addStaticSourceDirectory].
* We deliberately use addStaticSourceDirectory (not addGeneratedSourceDirectory) because
* addGeneratedSourceDirectory overrides the task's outputDirectory property, which would
* redirect generated files away from the user-visible path
* (build/generated/sources/valkyrie/<sourceSet>/kotlin).
* The compile β†’ gen task dependency is declared separately via AbstractKotlinCompile.dependsOn.
*/
@Suppress("UNCHECKED_CAST")
private fun Project.registerAndroidTasks(extension: ValkyrieExtension) {
val androidExt = extensions.findByType<CommonExtension>() ?: return
val androidComponents = extensions.findByType<AndroidComponentsExtension<*, *, *>>() ?: return

// Phase 1: register one task per source set (no srcDir β€” wired in phase 2)
androidExt.sourceSets.configureEach { sourceSet ->
registerTask(
project = this,
extension = extension,
sourceSetName = sourceSet.name,
addGeneratedSrcDir = { /* handled via addStaticSourceDirectory in phase 2 */ },
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Phase 2: wire the generated output directories into each variant's Kotlin compilation.
// We use addStaticSourceDirectory (not addGeneratedSourceDirectory) so that AGP does NOT
// override the task's outputDirectory convention β€” the files must land at the path our tests
// and users expect (build/generated/sources/valkyrie/<sourceSet>/kotlin).
// The compile β†’ gen task dependency is already declared globally via
// AbstractKotlinCompile.dependsOn(codegenTasks) above.
androidComponents.onVariants { variant ->
// Source sets that contribute to this variant:
// "main" + buildType (e.g. "debug") + flavors (e.g. "free") + combined (e.g. "freeDebug")
val contributingSourceSets = buildList {
add("main")
variant.buildType?.let { add(it) }
variant.productFlavors.forEach { (_, flavorName) -> add(flavorName) }
if (variant.productFlavors.isNotEmpty()) add(variant.name)
}

contributingSourceSets
.filter { sourceSetName ->
// Only wire tasks that were actually registered (safe guard)
"${TASK_NAME}${sourceSetName.capitalized()}" in tasks.names
}
.forEach { sourceSetName ->
val genDir = extension.outputDirectory
.get()
.dir("$sourceSetName/kotlin")
.asFile
.absolutePath
variant.sources.kotlin?.addStaticSourceDirectory(genDir)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ internal inline fun <reified T : Any> ExtensionContainer.create(

internal inline fun <reified T : Any> ExtensionContainer.getByType(): T = getByType(typeOf<T>())

internal inline fun <reified T : Any> ExtensionContainer.findByType(): T? = findByType(T::class.java)

internal inline fun <reified T> typeOf(): TypeOf<T> = object : TypeOf<T>() {}
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ package io.github.composegears.valkyrie.gradle.dsl
import org.gradle.api.Task
import org.gradle.api.tasks.TaskCollection

internal inline fun <reified T : Task> TaskCollection<in T>.withType(): TaskCollection<T> = withType(T::class.java)
@Suppress("UNCHECKED_CAST")
internal inline fun <reified T : Task> TaskCollection<*>.withType(): TaskCollection<T> {
return (this as TaskCollection<T>).withType(T::class.java)
}
Loading
Loading