Skip to content

Commit

Permalink
Merge pull request #17 from iodigital-com/feature/xcode-assets
Browse files Browse the repository at this point in the history
xcode assets
  • Loading branch information
LucaGobbo authored Nov 19, 2024
2 parents 8994650 + fb693af commit 9a4edc2
Show file tree
Hide file tree
Showing 6 changed files with 150 additions and 30 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,10 @@ See the example config in the `samples` directory.
cause a directory to be created.
- `useAndroidRasterScales`: A shorthand to create `mdpi`, `hdpi`, `xhdpi`, `xxhdpi`, `xxxhdpi`
exports of raster graphics. Ignored if `rasterScales` is defined
- `companionFileName`: The name of the generated companion file. A `/` will
cause a directory to be created. If this is set `companionFileTemplatePath` is required
- `companionFileTemplatePath`: The path to the Jinja2 template. See `samples/Contents.json.figex` for an example and see below for more details. If `companionFileName` is not set, this value is ignored
- `useXcodeAssetCompanionFile`: A shorthand to create xcode assets `Contents.json` companion files. Ignored if `companionFileName` is set

## Templating
The templating engine uses Jinja syntax. You can use loops, if statements and more. FigEx's templating is build with [jinjava](https://github.com/HubSpot/jinjava) which is also the base of HubSpot's [HubL templating system](https://developers.hubspot.com/docs/cms/hubl). This means the syntax for if-statements and loops also applies to FigEx, same goes for the filters available. Of course, HubSpot specific variables and functions are not available.
Expand All @@ -175,6 +179,13 @@ This templating is used in the `filter` and `fileNames` configurations.
- `set_id`: The id of the set of which this component is a part of, empty if not part of a set
- `scale`: A scale object representing the current scale for the export

### Templating for companion file export

This templating is used in the file at the `companionFileTemplatePath` configuration, and is the same as for the file name generation.

- `file_name`: The full filename passed in the file at the `companionFileName`, this is only set for the companion file export
- `file_name_relative`: the relative file name passed in the file at `companionFileName`, this is only set for the companion file export

### Templating for values export

This templating is used in the file at the `templatePath` configuration.
Expand Down
26 changes: 25 additions & 1 deletion figex-core/src/main/kotlin/com/iodigital/figex/ExportHelpers.kt
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,28 @@ fun filter(filter: String, context: Map<String, Any>): Boolean {
return jinjava.render(filter, context).filter { it.isLetter() }.lowercase().also {
verbose(tag = tag, message = "Applying filter `${filter}` to `$context` => $it")
} == "true"
}
}

internal var xcodeAssetsContentJSON = """
{
"images" : [
{
"filename" : "{{ file_name_relative }}",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
""".trimIndent()

internal val xcodeAssetsFolderContentJSON = """
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
""".trimIndent()
125 changes: 98 additions & 27 deletions figex-core/src/main/kotlin/com/iodigital/figex/IconExports.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ import com.iodigital.figex.api.FigmaImageExporter
import com.iodigital.figex.ext.asFigExComponent
import com.iodigital.figex.models.figex.FigExComponent
import com.iodigital.figex.models.figex.FigExConfig
import com.iodigital.figex.models.figex.FigExIconFormat
import com.iodigital.figex.models.figex.FigExConfig.Export.Icons.Companion.COMPANION_FILENAME_XCODE_ASSETS
import com.iodigital.figex.models.figma.FigmaFile
import com.iodigital.figex.utils.debug
import com.iodigital.figex.utils.info
import com.iodigital.figex.utils.verbose
import com.iodigital.figex.utils.warning
import kotlinx.coroutines.CoroutineScope
import io.ktor.util.normalizeAndRelativize
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
Expand Down Expand Up @@ -45,46 +44,118 @@ internal suspend fun performIconExport(
?: FigExConfig.Export.Icons.androidScales.takeIf { export.useAndroidRasterScales && export.format.isRaster }
?: listOf(FigExConfig.Export.Icons.Scale(1f))
//endregion
// Export files
//region Export files
components.asSequence().flatMap { component ->
scales.map {
Triple(component, it, createTemplateContext(file, it, component))
}
}.filter { (_, _, context) ->
filter(filter = export.filter, context = context)
}.toList().map { (component, scale, context) ->
val name = jinjava.render(export.fileNames, context)
.trim()
.replace("\n", "")
val name = jinjava.render(export.fileNames, context).trim().replace("\n", "")

Triple(
component,
scale,
"${scale.namePrefix}$name${scale.nameSuffix}.${export.format.suffix}"
ExportSet(
component = component,
scale = scale,
name = "${scale.namePrefix}$name${scale.nameSuffix}.${export.format.suffix}",
context = context
)
}.also {
it.distinctBy { it.first.key }
}.map { (component, scale, name) ->
}.also { export ->
export.distinctBy { it.component.key }
}.map { exportSet ->
async {
val start = System.currentTimeMillis()
verbose(tag = tag, message = " Downloading: ${component.fullName}@${scale.scale}x")
val outFile = destinationRoot.makeChild(name)
outFile.parentFile.mkdirs()
outFile.outputStream().use { out ->
exporter.downloadImage(
id = component.id,
format = export.format,
out = out,
scale = scale.scale,
)
}
verbose(
tag = tag,
message = " Downloading: ${exportSet.component.fullName}@${exportSet.scale.scale}x"
)

val outFile = destinationRoot.makeChild(exportSet.name)

downloadImage(
export = export,
exportSet = exportSet,
outFile = outFile,
exporter = exporter
)
generateCompanionFile(
export = export,
exportSet = exportSet,
outFile = outFile,
root = root
)

debug(
tag = tag,
message = " Downloaded: ${component.fullName}@${scale.scale}x => ${outFile.absolutePath} (${System.currentTimeMillis() - start}ms)"
message = " Downloaded: ${exportSet.component.fullName}@${exportSet.scale.scale}x => ${outFile.absolutePath} (${System.currentTimeMillis() - start}ms)"
)
}
}.forEach { deferred ->
deferred.await()
}
//endregion
}
}

private suspend fun downloadImage(
export: FigExConfig.Export.Icons,
exportSet: ExportSet,
outFile: File,
exporter: FigmaImageExporter
) {
outFile.parentFile.mkdirs()
outFile.outputStream().use { out ->
exporter.downloadImage(
id = exportSet.component.id,
format = export.format,
out = out,
scale = exportSet.scale.scale,
)
}
}

private fun generateCompanionFile(
export: FigExConfig.Export.Icons,
exportSet: ExportSet,
outFile: File,
root: File
) {
val fileName = export.companionFileName
?: COMPANION_FILENAME_XCODE_ASSETS.takeIf { export.useXcodeAssetCompanionFile }
?: return
val fileContent = when (fileName) {
COMPANION_FILENAME_XCODE_ASSETS -> xcodeAssetsContentJSON
else -> requireNotNull(export.companionFileTemplatePath) {
"When companionFileName is defined, companionFileTemplatePath is required but currently null"
}.let {
root.makeChild(it).readText()
}
}

verbose(tag = tag, message = " Generating companion file: ${exportSet.component.fullName}")
val companionFile = outFile.parentFile.makeChild(fileName)
companionFile.parentFile.mkdirs()

if (export.useXcodeAssetCompanionFile) {
val parent = outFile.parentFile.parentFile
val parentContentsJSON = parent.makeChild("Contents.json")
parentContentsJSON.writeText(xcodeAssetsFolderContentJSON)
}

val companionFileContent = jinjava.render(
fileContent,
exportSet.context + mapOf(
"file_name" to exportSet.name,
"file_name_relative" to (outFile.relativeToOrNull(companionFile)
?.normalizeAndRelativize()?.path ?: exportSet.name)
)
)

companionFile.writeText(companionFileContent)
}

private data class ExportSet(
val component: FigExComponent,
val scale: FigExConfig.Export.Icons.Scale,
val name: String,
val context: Map<String, Any>
)
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ internal fun performValuesExport(
file = file,
defaultMode = export.defaultMode ?: "",
values = values,
components =components,
components = components,
filter = export.filter,
) + export.templateVariables
val template = root.makeChild(export.templatePath)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ data class FigExConfig(
val format: FigExIconFormat,
val rasterScales: List<Scale>? = null,
val useAndroidRasterScales: Boolean = false,
val useXcodeAssetCompanionFile: Boolean = false,
val companionFileName: String? = null,
val companionFileTemplatePath: String? = null,
) : Export() {
companion object {
val androidScales = listOf(
Expand All @@ -41,6 +44,8 @@ data class FigExConfig(
Scale(3f, "drawable-xxhdpi/"),
Scale(4f, "drawable-xxxhdpi/"),
)

internal const val COMPANION_FILENAME_XCODE_ASSETS = "Contents.json"
}

@Serializable
Expand Down
11 changes: 10 additions & 1 deletion samples/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"type": "icons",
"format": "androidxml",
"filter": "{% if full_name|startsWith('content', true) %} true {% else %} false {% endif %}",
"fileNames" : "{{ full_name|replaceSpecialChars('_')|lowercase }}",
"fileNames": "{{ full_name|replaceSpecialChars('_')|lowercase }}",
"destinationPath": "../sample_output/icons/drawable",
"clearDestination": true
},
Expand All @@ -45,6 +45,15 @@
"destinationPath": "../sample_output/icons",
"clearDestination": true,
"useAndroidRasterScales": true
},
{
"type": "icons",
"format": "pdf",
"filter": "{% if full_name|startsWith('content', true) %} true {% else %} false {% endif %}",
"fileNames": "{{ full_name|replaceSpecialChars('_')|lowercase }}.imageasset/{{ full_name|replaceSpecialChars('_')|lowercase }}",
"destinationPath": "../sample_output/Media.xcassets",
"clearDestination": true,
"useXcodeAssetCompanionFile": true
}
]
}

0 comments on commit 9a4edc2

Please sign in to comment.