package info.mechyrdia.lore
-import com.samskivert.mustache.Escapers
-import com.samskivert.mustache.Mustache
-import info.mechyrdia.Configuration
-import info.mechyrdia.JsonFileCodec
import info.mechyrdia.application
import io.ktor.server.application.*
-import io.ktor.util.*
-import kotlinx.serialization.json.*
-import java.io.File
-import javax.script.ScriptException
-
-@JvmInline
-value class JsonPath private constructor(private val pathElements: List<String>) {
- constructor(path: String) : this(path.split('.').filterNot { it.isBlank() })
-
- operator fun component1() = pathElements.firstOrNull()
- operator fun component2() = JsonPath(pathElements.drop(1))
-
- override fun toString(): String {
- return pathElements.joinToString(separator = ".")
- }
-}
-
-operator fun JsonElement.get(path: JsonPath): JsonElement {
- val (pathHead, pathTail) = path
- pathHead ?: return this
-
- return when (this) {
- is JsonObject -> this.getValue(pathHead)[pathTail]
- is JsonArray -> this[pathHead.toInt()][pathTail]
- is JsonPrimitive -> throw NoSuchElementException("Cannot resolve path $path on JSON primitive $this")
- }
-}
-
-@JvmInline
-value class JsonImport private constructor(private val importFrom: Pair<File, JsonPath>) {
- fun resolve(): Pair<File, JsonElement> {
- return try {
- importFrom.let { (file, path) ->
- file to JsonFileCodec.parseToJsonElement(file.readText())[path]
- }
- } catch (ex: RuntimeException) {
- val filePath = importFrom.first.toRelativeString(File(Configuration.CurrentConfiguration.jsonDocDir))
- val jsonPath = importFrom.second
- throw IllegalArgumentException("Unable to resolve JSON path $jsonPath on file $filePath", ex)
- }
- }
-
- companion object {
- operator fun invoke(statement: String, currentFile: File): JsonImport? {
- if (!statement.startsWith('@')) return null
- val splitterIndex = statement.lastIndexOf('#')
-
- val (filePath, jsonPath) = if (splitterIndex != -1)
- statement.substring(1, splitterIndex) to statement.substring(splitterIndex + 1)
- else
- statement.substring(1) to ""
-
- val file = if (filePath.startsWith('/'))
- File(Configuration.CurrentConfiguration.jsonDocDir).combineSafe("$filePath.json")
- else
- currentFile.parentFile.combineSafe("$filePath.json")
-
- if (!file.isFile)
- throw IllegalArgumentException("JSON import path '$filePath' does not point to a file")
-
- return JsonImport(file to JsonPath(jsonPath))
- }
- }
-}
+import io.pebbletemplates.pebble.PebbleEngine
+import io.pebbletemplates.pebble.error.PebbleException
+import java.io.StringWriter
object PreParser {
- private val compiler = Mustache.compiler()
- .withEscaper(Escapers.NONE)
- .defaultValue("{{ MISSING VALUE \"{{name}}\" }}")
- .withLoader { File(Configuration.CurrentConfiguration.templateDir).combineSafe("$it.tpl").bufferedReader() }
-
- private fun convertJson(json: JsonElement, currentFile: File): Any? = when (json) {
- JsonNull -> null
- is JsonPrimitive -> if (json.isString) {
- JsonImport(json.content, currentFile)?.let { jsonImport ->
- val (nextFile, jsonData) = jsonImport.resolve()
- convertJson(jsonData, nextFile)
- } ?: json.content
- } else json.intOrNull ?: json.double
-
- is JsonObject -> json.mapValues { (_, it) -> convertJson(it, currentFile) }
- is JsonArray -> json.map { convertJson(it, currentFile) }
- }
-
- private fun loadJsonContext(name: String): Map<*, *> =
- File(Configuration.CurrentConfiguration.jsonDocDir)
- .combineSafe("$name.json")
- .takeIf { it.isFile }
- ?.let { file ->
- val text = file.readText()
- val data = convertJson(JsonFileCodec.parseToJsonElement(text), file)
- if (data !is Map<*, *>)
- error("JSON Object expected in file $name, got $text")
-
- data
- } ?: emptyMap<String, Any?>()
+ private val pebble = PebbleEngine.Builder()
+ .addEscapingStrategy("none", PebbleNoEscaping)
+ .defaultEscapingStrategy("none")
+ .autoEscaping(false)
+ .loader(PebbleTemplateLoader)
+ .registerExtensionCustomizer(::PebbleExtensionCustomizer)
+ .extension(PebbleFactbooksExtension)
+ .build()
fun preparse(name: String, content: String): String {
return try {
- val template = compiler.compile(content)
- val context = loadJsonContext(name) + PreParserFunctions.getFunctions()
- template.execute(context)
- } catch (ex: RuntimeException) {
+ val template = pebble.getLiteralTemplate(content)
+ val context = PebbleJsonLoader.loadJsonContextOrEmpty(name).toMutableMap()
+ val writer = StringWriter()
+ template.evaluate(writer, context)
+ return writer.toString()
+ } catch (ex: PebbleException) {
application.log.warn("Runtime error pre-parsing factbook $name", ex)
"[h1]Error[/h1]\n\nThere was a runtime error pre-parsing this factbook: ${ex.message}"
- } catch (ex: ScriptException) {
- application.log.warn("Scripting error pre-parsing factbook $name", ex)
- "[h1]Error[/h1]\n\nThere was a scripting error pre-parsing this factbook: ${ex.message}"
}
}
}
--- /dev/null
+package info.mechyrdia.lore
+
+import info.mechyrdia.Configuration
+import info.mechyrdia.JsonFileCodec
+import info.mechyrdia.JsonStorageCodec
+import io.ktor.util.*
+import io.pebbletemplates.pebble.error.PebbleException
+import io.pebbletemplates.pebble.extension.*
+import io.pebbletemplates.pebble.extension.Function
+import io.pebbletemplates.pebble.extension.escaper.EscapeFilter
+import io.pebbletemplates.pebble.extension.escaper.EscapingStrategy
+import io.pebbletemplates.pebble.extension.escaper.RawFilter
+import io.pebbletemplates.pebble.extension.i18n.i18nFunction
+import io.pebbletemplates.pebble.loader.Loader
+import io.pebbletemplates.pebble.template.EvaluationContext
+import io.pebbletemplates.pebble.template.PebbleTemplate
+import io.pebbletemplates.pebble.tokenParser.*
+import kotlinx.serialization.json.*
+import java.io.File
+import java.io.IOException
+import java.io.Reader
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+import javax.script.*
+import kotlin.reflect.jvm.jvmName
+
+object PebbleNoEscaping : EscapingStrategy {
+ override fun escape(input: String): String {
+ return input
+ }
+}
+
+data class PebbleTemplateCacheKey(val fileName: String, val lastModified: Long) {
+ constructor(name: String) : this(name, File(Configuration.CurrentConfiguration.templateDir).combineSafe("$name.tpl").lastModified())
+
+ val file: File
+ get() = File(Configuration.CurrentConfiguration.templateDir).combineSafe("$fileName.tpl")
+
+ companion object {
+ fun exists(name: String) = File(Configuration.CurrentConfiguration.templateDir).combineSafe("$name.tpl").isFile
+ }
+}
+
+object PebbleTemplateLoader : Loader<PebbleTemplateCacheKey> {
+ override fun getReader(cacheKey: PebbleTemplateCacheKey): Reader {
+ return cacheKey.file.reader()
+ }
+
+ override fun setCharset(charset: String?) {
+ // no-op
+ }
+
+ override fun setPrefix(prefix: String) {
+ // no-op
+ }
+
+ override fun setSuffix(suffix: String) {
+ // no-op
+ }
+
+ override fun resolveRelativePath(relativePath: String, anchorPath: String): String {
+ val templateDir = File(Configuration.CurrentConfiguration.templateDir)
+ if ('\n' in anchorPath) // Probably a raw template string
+ return relativePath
+
+ return templateDir.combineSafe("$anchorPath/$relativePath").toRelativeString(templateDir)
+ }
+
+ override fun createCacheKey(templateName: String): PebbleTemplateCacheKey {
+ return PebbleTemplateCacheKey(templateName)
+ }
+
+ override fun resourceExists(templateName: String): Boolean {
+ return PebbleTemplateCacheKey.exists(templateName)
+ }
+}
+
+class PebbleExtensionCustomizer(delegate: Extension) : ExtensionCustomizer(delegate) {
+ override fun getFilters(): MutableMap<String, Filter> {
+ return super.getFilters().orEmpty().filterValues { filter ->
+ filter !is EscapeFilter && filter !is RawFilter
+ }.toMutableMap()
+ }
+
+ override fun getFunctions(): MutableMap<String, Function> {
+ return super.getFunctions().orEmpty().filterValues { function ->
+ function !is i18nFunction
+ }.toMutableMap()
+ }
+
+ override fun getTokenParsers(): MutableList<TokenParser> {
+ return super.getTokenParsers().orEmpty().filter { tokenParser ->
+ tokenParser !is AutoEscapeTokenParser && tokenParser !is CacheTokenParser && tokenParser !is FlushTokenParser && tokenParser !is ParallelTokenParser
+ }.toMutableList()
+ }
+}
+
+object PebbleFactbooksExtension : AbstractExtension() {
+ override fun getFilters(): MutableMap<String, Filter> {
+ return mutableMapOf(
+ "toJson" to PebbleToJsonFilter,
+ "fromJson" to PebbleFromJsonFilter,
+ "script" to PebbleScriptFilter,
+ )
+ }
+
+ override fun getFunctions(): MutableMap<String, Function> {
+ return mutableMapOf(
+ "loadJson" to PebbleLoadJsonFunction,
+ "script" to PebbleScriptFunction,
+ )
+ }
+}
+
+@JvmInline
+value class JsonPath private constructor(private val pathElements: List<String>) {
+ constructor(path: String) : this(path.split('.').filterNot { it.isBlank() })
+
+ operator fun component1() = pathElements.firstOrNull()
+ operator fun component2() = JsonPath(pathElements.drop(1))
+
+ override fun toString(): String {
+ return pathElements.joinToString(separator = ".")
+ }
+
+ companion object {
+ val Empty = JsonPath(emptyList())
+ }
+}
+
+operator fun JsonElement.get(path: JsonPath): JsonElement {
+ val (pathHead, pathTail) = path
+ pathHead ?: return this
+
+ return when (this) {
+ is JsonObject -> this.getValue(pathHead)[pathTail]
+ is JsonArray -> this[pathHead.toInt()][pathTail]
+ is JsonPrimitive -> throw NoSuchElementException("Cannot resolve path $path on JSON primitive $this")
+ }
+}
+
+@JvmInline
+value class JsonImport private constructor(private val importFrom: Pair<File, JsonPath>) {
+ fun resolve(): Pair<File, JsonElement> {
+ return try {
+ importFrom.let { (file, path) ->
+ file to JsonFileCodec.parseToJsonElement(file.readText())[path]
+ }
+ } catch (ex: RuntimeException) {
+ val filePath = importFrom.first.toRelativeString(File(Configuration.CurrentConfiguration.jsonDocDir))
+ val jsonPath = importFrom.second
+ throw IllegalArgumentException("Unable to resolve JSON path $jsonPath on file $filePath", ex)
+ }
+ }
+
+ companion object {
+ operator fun invoke(statement: String, currentFile: File): JsonImport? {
+ if (!statement.startsWith('@')) return null
+ val splitterIndex = statement.lastIndexOf('#')
+
+ val (filePath, jsonPath) = if (splitterIndex != -1)
+ statement.substring(1, splitterIndex) to statement.substring(splitterIndex + 1)
+ else
+ statement.substring(1) to null
+
+ val file = if (filePath.startsWith('/'))
+ File(Configuration.CurrentConfiguration.jsonDocDir).combineSafe("$filePath.json")
+ else
+ currentFile.parentFile.combineSafe("$filePath.json")
+
+ if (!file.isFile)
+ throw IllegalArgumentException("JSON import path '$filePath' does not point to a file")
+
+ return JsonImport(file to (jsonPath?.let { JsonPath(it) } ?: JsonPath.Empty))
+ }
+ }
+}
+
+object PebbleJsonLoader {
+ private fun resolveImports(json: JsonElement, currentFile: File): JsonElement = when (json) {
+ JsonNull -> JsonNull
+ is JsonPrimitive -> if (json.isString) {
+ JsonImport(json.content, currentFile)?.let { jsonImport ->
+ val (nextFile, jsonData) = jsonImport.resolve()
+ resolveImports(jsonData, nextFile)
+ } ?: json
+ } else json
+
+ is JsonObject -> JsonObject(json.mapValues { (_, it) -> resolveImports(it, currentFile) })
+ is JsonArray -> JsonArray(json.map { resolveImports(it, currentFile) })
+ }
+
+ fun convertJson(json: JsonElement): Any? = when (json) {
+ JsonNull -> null
+ is JsonPrimitive -> if (json.isString)
+ json.content
+ else
+ json.intOrNull ?: json.double
+
+ is JsonObject -> json.mapValues { (_, it) -> convertJson(it) }
+ is JsonArray -> json.map { convertJson(it) }
+ }
+
+ fun deconvertJson(data: Any?): JsonElement = when (data) {
+ null -> JsonNull
+ is String -> JsonPrimitive(data)
+ is Number -> JsonPrimitive(data)
+ is Boolean -> JsonPrimitive(data)
+ is List<*> -> JsonArray(data.map { deconvertJson(it) })
+ is Set<*> -> JsonArray(data.map { deconvertJson(it) })
+ is Map<*, *> -> JsonObject(data.map { (k, v) -> k.toString() to deconvertJson(v) }.toMap())
+ else -> throw ClassCastException("Expected null, String, Number, Boolean, List, Set, or Map for converted data, got $data or type ${data::class.jvmName}")
+ }
+
+ fun loadJson(name: String): JsonElement =
+ File(Configuration.CurrentConfiguration.jsonDocDir)
+ .combineSafe("$name.json")
+ .takeIf { it.isFile }
+ .let { file ->
+ file ?: throw IOException("Unable to find $name as a file")
+
+ val text = file.readText()
+ resolveImports(JsonFileCodec.parseToJsonElement(text), file)
+ }
+
+ fun loadJsonContext(name: String): Map<String, Any?> = loadJson(name).let { json ->
+ val data = convertJson(json)
+ if (data !is Map<*, *>)
+ throw IOException("JSON Object expected in file $name, got $json")
+
+ data.mapKeys { (k, _) -> k.toString() }
+ }
+
+ fun loadJsonContextOrEmpty(name: String): Map<String, Any?> = try {
+ loadJsonContext(name)
+ } catch (ex: IOException) {
+ emptyMap()
+ }
+}
+
+object PebbleToJsonFilter : Filter {
+ override fun getArgumentNames(): MutableList<String> {
+ return mutableListOf()
+ }
+
+ override fun apply(input: Any?, args: MutableMap<String, Any>?, self: PebbleTemplate?, context: EvaluationContext?, lineNumber: Int): Any {
+ return PebbleJsonLoader.deconvertJson(input).toString()
+ }
+}
+
+object PebbleFromJsonFilter : Filter {
+ override fun getArgumentNames(): MutableList<String> {
+ return mutableListOf()
+ }
+
+ override fun apply(input: Any?, args: MutableMap<String, Any>?, self: PebbleTemplate?, context: EvaluationContext?, lineNumber: Int): Any? {
+ return PebbleJsonLoader.convertJson(JsonStorageCodec.parseToJsonElement(input.toString()))
+ }
+}
+
+object PebbleLoadJsonFunction : Function {
+ override fun getArgumentNames(): MutableList<String> {
+ return mutableListOf("data", "path")
+ }
+
+ override fun execute(args: MutableMap<String, Any>, self: PebbleTemplate, context: EvaluationContext, lineNumber: Int): Any? {
+ val dataName = args["data"]?.toString()
+ ?: throw PebbleException(null, "Missing 'data' argument", lineNumber, self.name)
+
+ val data = try {
+ PebbleJsonLoader.loadJson(dataName)
+ } catch (ex: IOException) {
+ throw PebbleException(ex, ex.message, lineNumber, self.name)
+ }
+
+ val dataPath = args["path"]?.toString()?.let { JsonPath(it) } ?: JsonPath.Empty
+ return try {
+ PebbleJsonLoader.convertJson(data[dataPath])
+ } catch (ex: NoSuchElementException) {
+ throw PebbleException(ex, "Unable to resolve path $dataPath in JSON $data", lineNumber, self.name)
+ }
+ }
+}
+
+object PebbleScriptLoader {
+ private val scriptEngine = ThreadLocal.withInitial { ScriptEngineManager().getEngineByExtension("groovy") }
+ private val hasher = ThreadLocal.withInitial { MessageDigest.getInstance("SHA-256") }
+ private val cache = ConcurrentHashMap<String, CompiledScript>()
+
+ fun loadFunction(name: String): CompiledScript? {
+ val scriptFile = File(Configuration.CurrentConfiguration.scriptDir).combineSafe("$name.groovy")
+ if (!scriptFile.canRead())
+ return null
+
+ val script = scriptFile.readText()
+ val digest = hasher.get().digest(script.toByteArray()).joinToString(separator = "") { it.toUByte().toString(16) }
+ cache[digest]?.let { return it }
+
+ val compiledScript = (scriptEngine.get() as Compilable).compile(script)
+ cache[digest] = compiledScript
+ return compiledScript
+ }
+}
+
+object PebbleScriptFilter : Filter {
+ override fun getArgumentNames(): MutableList<String> {
+ return mutableListOf("script")
+ }
+
+ override fun apply(input: Any?, args: MutableMap<String, Any?>, self: PebbleTemplate, context: EvaluationContext, lineNumber: Int): Any {
+ val scriptName = args["script"]?.toString()
+ ?: throw PebbleException(null, "Missing 'script' argument", lineNumber, self.name)
+
+ val script = PebbleScriptLoader.loadFunction(scriptName)
+ ?: throw PebbleException(null, "Script $scriptName could not be found", lineNumber, self.name)
+
+ val bindings = SimpleBindings()
+ bindings["text"] = input
+ bindings["ctx"] = java.util.function.Function(context::getVariable)
+ bindings["args"] = args.toMutableMap().apply { remove("script") }
+
+ return try {
+ script.eval(bindings)
+ } catch (ex: ScriptException) {
+ throw PebbleException(ex, "Unhandled ScriptException from $scriptName", lineNumber, self.name)
+ }
+ }
+}
+
+object PebbleScriptFunction : Function {
+ override fun getArgumentNames(): MutableList<String> {
+ return mutableListOf("script")
+ }
+
+ override fun execute(args: MutableMap<String, Any>, self: PebbleTemplate, context: EvaluationContext, lineNumber: Int): Any {
+ val scriptName = args["script"]?.toString()
+ ?: throw PebbleException(null, "Missing 'script' argument", lineNumber, self.name)
+
+ val script = PebbleScriptLoader.loadFunction(scriptName)
+ ?: throw PebbleException(null, "Script $scriptName could not be found", lineNumber, self.name)
+
+ val bindings = SimpleBindings()
+ bindings["ctx"] = java.util.function.Function(context::getVariable)
+ bindings["args"] = args.toMutableMap().apply { remove("script") }
+
+ return try {
+ script.eval(bindings)
+ } catch (ex: ScriptException) {
+ throw PebbleException(ex, "Unhandled ScriptException from $scriptName", lineNumber, self.name)
+ }
+ }
+}