Skip to content

nminet/kostache

Repository files navigation

kostache

License: MIT Kotlin

A kotlin-multiplatform implementation of Mustache templates.

Reference

The reference specification for the Mustache template system is in Mustache Specification. It defines required core modules as well as optional modules.

This implementation includes all core modules as well as the optional inheritance, lambdas and dynamic-names modules, passing all standard tests.

API

Mustache class

The main API is the Mustache class, capturing a template and the environment to render data.

import dev.nminet.kmm.mustache.Mustache

class Mustache(
    template: String,
    partials: TemplateStore = emptyStore,
    wrap: (Any?) -> Context = KotlinxJsonContext.wrap
) {
    fun render(data: Any? = null): String
}

The constructor parses a template and returns an object ready to render data. template must be a valid Mustache template, otherwise IllegalStateException is raised. partials indicate how to obtain partials from name. wrap is a callable producing a mustache context from raw data.

The render method produces a String by feeding data into the template. if data is an instance of Context it is used as is. Otherwise, wrap is called to wrap the data for rendering.

Template class

The Template class captures a parse result and can render when given a context.

import dev.nminet.kmm.mustache.Template

class Template(
    template: String
) {
    fun render(
        context: Context,
        partials: TemplateStore = emptyStore
    ): String
}

template must be a valid Mustache template, otherwise IllegalStateException is raised.

The exception can be avoided with

val template = Template.load("{{invalid}") ?: Template("fallback")
val result = template.render(KClassContext(null))
check(result == "fallback")

Data wrappers (Context class)

Data wrappers provide an API for mustache to walk through the data to be rendered. The library provides three implementations of Context.

KotlinxJsonContext class (default wrapper)

This wrapper uses kotlinx.serialization.json to process JSON data.

val mustache = Mustache(
    template = "hello {{you}}!"
)
val result = mustache.render("""{ "you": "world" }""")
  • JsonArray iterate in sections
  • JsonNull, JsonPrimitive holding a boolean "false", and empty JsonArray are false. All other values are true.
  • JsonObject and JsonPrimitive process as regular values

Classes annotated with @kotlinx.serialization.Serializable can be rendered

@Serializable
class Widget(val you: String)

val mustache = Mustache(
    template = "hello {{you}}!"
)
val widget = Widget("world")

mustache.render(widget.asJsonElement)

MapsAndListsContext class

This wrapper takes data from kotlin Map and List instances.

val mustache = Mustache(
    template = "hello {{you}}!",
    wrap = ::MapsAndListsContext
)
val result = mustache.render(mapOf("you" to "world"))
  • List iterate in sections
  • null, Boolean false and empty List are false. All other values are true
  • callable Map entries with type () -> String and (String) -> String act as mustache lambdas
  • other Map entries process as regular values

Processing of map entries that contain Kotlin lambdas depends on position in Mustache source.

  • In interpolation position, if the lambda has no parameter and returns a Kotlin String it is interpreted as a Mustache lambda. If the lambda has no parameter and does not return a String, the result is converted to String and rendered as a Mustache value.
  • In section position, if the lambda has one String parameter and returns a String, it is interpreted as a Mustache lambda and called with the body of the section. If the lambda has zero or one String parameter and does not return a String the lambda is called (with the section body if it takes a parameter) and result is interpreted as an object.
  • kotlin lambdas in the middle of dotted names are called (passing the section body if it takes a parameter) and the result is always interpreted as objects, never as Mustache lambda.

MapsAndListsContext is suitable for output from snakeyaml.

import org.snakeyaml.engine.v2.api.Load
import org.snakeyaml.engine.v2.api.LoadSettings

val yamlLoader = Load(LoadSettings.builder().build())
val mustache = Mustache(
    template = "hello {{you}}!",
    wrap = ::MapsAndListsContext
)
val data = yamlLoader.loadFromString("you: world")
val result = mustache.render(data)

KClassContext class (jvm only)

This wrapper uses reflection to process kotlin classes.

data class Who(val you: String)

val mustache = Mustache(
    template = "hello {{you}}!",
    wrap = ::KClassContext
)
val result = mustache.render(Who("world"))
  • List, Array and Set iterate in sections
  • false, null, empty List, Array, Set are falsey. All other values are truthy.
  • callable public fields (lambdas, methods, "operator fun invoke") with zero or one String parameter, returning a String act as mustache lambdas, respectively for values and sections.
  • other public fields are converted with toString (as per spec, callables are invoked before conversion).
  • Enum fields can be used as section where only the current value is truthy and evaluates to its name.

User defined wrappers

The Context class is abstract and can be derived to manage specialized data source as well as adjust the definition of truth (for historical reasons the mustache specification is imprecise with respect to this).

abstract class Context(
    value: Any?,
    val parent: Context?
) {
    // indicate if the context renders as a regular or inverted section
    abstract fun isFalsey(): Boolean

    // get all child contexts for an iterable section, or null if not iterable
    abstract fun push(): List<Context>?

    // get the context associated to a name for a regular section
    abstract fun push(name: String, body: String?, onto: Context): Context?

    // mustache lambda if available
    open fun asLambda(): String? = null
    
    // text to render
    open fun asValue(): String = value.toString()
}

When the value passed in constructor is a callable with no parameter, it is invoked and the result becomes the content's actual value.

Dotted names cause successive calls to push - one for each segment of the dotted name.
When pushing a name in section position, body contains the unprocessed text of the section tag. In case of dotted names all segments receive the same value.
For names in interpolation position, body is not set.

Template stores

The TemplateStore functional interface is used by the rendering process to resolve partials.

    fun interface TemplateStore {
        operator fun get(name: String): Template?
    }

    val emptyStore: TemplateStore { _ ->
        Template()
    }

Three implementations are provided (in addition to emptyStore)

TemplateFolder class

Look in a directory given by path for files with extension.

class TemplateFolder(
    path: String,
    extension: String = "mustache"
) : TemplateStore

If extension is not empty it is appended to names supplied to get. Otherwise the parameter to get is used as given.

The TemplateFolder instance maintains a cache of compiled templates. If the file is modified on the filesystem and the new contents should be used, the cache can be cleared with

templateFolder.clearCache()

TemplateMap class

Get template from a Map of name to mustache source code.

class TemplateMap(
    sourceMap: Map<String, String>
) : TemplateStore

An invalid template in sourceMap will trigger IllegalStateException on construction.

TemplateFromResources class (jvm only)

Get template from a resource folder in a jar.

class TemplateFromResources(
    templateDir: String,
    extension: String = "mustache",
    classLoader: ClassLoader = ClassLoader.getSystemClassLoader()
) : TemplateStore

If extension is not empty it is appended to names supplied to get. Otherwise the parameter to get is used as given.

This class maintains a map of compiled templates that remains in memory until the instance is collected.

Caveats

IOS/OSX cannot check the type of a kotlin lambda parameter. Because of this, kotlin lambdas in section position taking one parameter must take a String parameter (which will receive the section body). Any other parameter type triggers undefined behaviour.

The * (asterisk) character following the opening sigil for a partial or parent tag indicates a dynamic name. In any other position the asterisk is handled as a regular symbol character.

TODO

Need to do profiling.

  • Performance with kotlin-reflect would probably benefit from caching.
  • Check if option to inhibit support for lambdas and callable values in 'MapsAndListsContext' has significant benefit.
  • Other optimizations?

Other KMM targets?

Dependencies

The implementation depends on the kotlin standard library, including kotlinx serialization and kotlinx reflection.

Noel MINET

2023-06-06

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages