Compare commits
18 Commits
25b9a3c3cc
...
0c4ae405b8
Author | SHA1 | Date | |
---|---|---|---|
0c4ae405b8 | |||
8245031896 | |||
395fea432e | |||
eddeea8758 | |||
35cd0e828a | |||
8746360f14 | |||
b66c6b4fe6 | |||
3b318c3a8b | |||
018b52aaff | |||
3d44ea9a88 | |||
c0f869f6e3 | |||
738f41265f | |||
eeaa080a88 | |||
c986ede110 | |||
d5edf5e989 | |||
aff7e88c7e | |||
1ac5768b14 | |||
40664db80d |
@ -1,10 +1,13 @@
|
|||||||
|
import space.kscience.gradle.useApache2Licence
|
||||||
|
import space.kscience.gradle.useSPCTeam
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("space.kscience.gradle.project")
|
id("space.kscience.gradle.project")
|
||||||
}
|
}
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
group = "space.kscience"
|
group = "space.kscience"
|
||||||
version = "0.1.0-dev-1"
|
version = "0.2.0-dev-1"
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
@ -12,10 +15,13 @@ allprojects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val dataforgeVersion by extra("0.6.1-dev-6")
|
val dataforgeVersion by extra("0.8.0")
|
||||||
|
|
||||||
ksciencePublish {
|
ksciencePublish {
|
||||||
github("SciProgCentre", "snark")
|
pom("https://github.com/SciProgCentre/snark") {
|
||||||
space("https://maven.pkg.jetbrains.space/mipt-npm/p/sci/maven")
|
useApache2Licence()
|
||||||
|
useSPCTeam()
|
||||||
|
}
|
||||||
|
repository("spc","https://maven.sciprog.center/kscience")
|
||||||
// sonatype()
|
// sonatype()
|
||||||
}
|
}
|
@ -1,3 +1,3 @@
|
|||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
||||||
toolsVersion=0.14.9-kotlin-1.8.20
|
toolsVersion=0.15.2-kotlin-1.9.22
|
2
gradle/wrapper/gradle-wrapper.properties
vendored
2
gradle/wrapper/gradle-wrapper.properties
vendored
@ -1,5 +1,5 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
rootProject.name = "snark"
|
rootProject.name = "snark"
|
||||||
|
|
||||||
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||||
//enableFeaturePreview("VERSION_CATALOGS")
|
|
||||||
|
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
|
|
||||||
@ -41,5 +40,6 @@ include(
|
|||||||
":snark-gradle-plugin",
|
":snark-gradle-plugin",
|
||||||
":snark-core",
|
":snark-core",
|
||||||
":snark-html",
|
":snark-html",
|
||||||
":snark-ktor"
|
":snark-ktor",
|
||||||
|
":snark-pandoc"
|
||||||
)
|
)
|
@ -0,0 +1,34 @@
|
|||||||
|
package space.kscience.snark
|
||||||
|
|
||||||
|
import space.kscience.dataforge.data.Data
|
||||||
|
import space.kscience.dataforge.data.DataTree
|
||||||
|
import space.kscience.dataforge.data.GenericDataTree
|
||||||
|
import space.kscience.dataforge.names.NameToken
|
||||||
|
import kotlin.reflect.KType
|
||||||
|
|
||||||
|
public class DataTreeWithDefault<T>(public val tree: DataTree<T>, public val default: DataTree<T>) :
|
||||||
|
DataTree<T> {
|
||||||
|
override val dataType: KType get() = tree.dataType
|
||||||
|
|
||||||
|
override val self: DataTreeWithDefault<T> get() = this
|
||||||
|
|
||||||
|
override val data: Data<T>? get() = tree.data ?: default.data
|
||||||
|
|
||||||
|
private fun mergeItems(
|
||||||
|
treeItems: Map<NameToken, DataTree<T>>,
|
||||||
|
defaultItems: Map<NameToken, DataTree<T>>,
|
||||||
|
): Map<NameToken, DataTree<T>> {
|
||||||
|
val mergedKeys = treeItems.keys + defaultItems.keys
|
||||||
|
return mergedKeys.associateWith {
|
||||||
|
val treeItem = treeItems[it]
|
||||||
|
val defaultItem = defaultItems[it]
|
||||||
|
when {
|
||||||
|
treeItem == null -> defaultItem!!
|
||||||
|
defaultItem == null -> treeItem
|
||||||
|
else -> DataTreeWithDefault(treeItem, defaultItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val items: Map<NameToken, GenericDataTree<T, *>> get() = mergeItems(tree.items, default.items)
|
||||||
|
}
|
@ -0,0 +1,61 @@
|
|||||||
|
package space.kscience.snark
|
||||||
|
|
||||||
|
import space.kscience.dataforge.actions.AbstractAction
|
||||||
|
import space.kscience.dataforge.data.*
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.MutableMeta
|
||||||
|
import space.kscience.dataforge.meta.copy
|
||||||
|
import space.kscience.dataforge.names.*
|
||||||
|
import kotlin.reflect.KType
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An action to change header (name and meta) without changing the data itself or its computation state
|
||||||
|
*/
|
||||||
|
public class ReWrapAction<R : Any>(
|
||||||
|
type: KType,
|
||||||
|
private val newMeta: MutableMeta.(name: Name) -> Unit = {},
|
||||||
|
private val newName: (name: Name, meta: Meta?) -> Name,
|
||||||
|
) : AbstractAction<R, R>(type) {
|
||||||
|
override fun DataSink<R>.generate(data: DataTree<R>, meta: Meta) {
|
||||||
|
data.forEach { namedData ->
|
||||||
|
put(
|
||||||
|
newName(namedData.name, namedData.meta),
|
||||||
|
namedData.data.withMeta(namedData.meta.copy { newMeta(namedData.name) })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun DataSink<R>.update(source: DataTree<R>, meta: Meta, namedData: NamedData<R>) {
|
||||||
|
put(
|
||||||
|
newName(namedData.name, namedData.meta),
|
||||||
|
namedData.withMeta(namedData.meta.copy { newMeta(namedData.name) })
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
public inline fun <reified R : Any> removeExtensions(
|
||||||
|
vararg bypassExtensions: String,
|
||||||
|
noinline newMeta: MutableMeta.(name: Name) -> Unit = {},
|
||||||
|
): ReWrapAction<R> = ReWrapAction(typeOf<R>(), newMeta = newMeta) { name, _ ->
|
||||||
|
name.replaceLast { token ->
|
||||||
|
val extension = token.body.substringAfterLast('.')
|
||||||
|
if (extension in bypassExtensions) {
|
||||||
|
NameToken(token.body.removeSuffix(".$extension"))
|
||||||
|
} else {
|
||||||
|
token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public inline fun <reified R : Any> removeIndex(): ReWrapAction<R> = ReWrapAction<R>(typeOf<R>()) { name, _ ->
|
||||||
|
if (name.endsWith("index")) name.cutLast() else name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public inline fun <reified R : Any> ReWrapAction(
|
||||||
|
noinline newMeta: MutableMeta.(name: Name) -> Unit = {},
|
||||||
|
noinline newName: (Name, Meta?) -> Name,
|
||||||
|
): ReWrapAction<R> = ReWrapAction(typeOf<R>(), newMeta, newName)
|
||||||
|
|
@ -0,0 +1,56 @@
|
|||||||
|
package space.kscience.snark
|
||||||
|
|
||||||
|
import kotlinx.io.readByteArray
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.context.PluginFactory
|
||||||
|
import space.kscience.dataforge.context.PluginTag
|
||||||
|
import space.kscience.dataforge.context.gather
|
||||||
|
import space.kscience.dataforge.io.IOPlugin
|
||||||
|
import space.kscience.dataforge.io.IOReader
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.get
|
||||||
|
import space.kscience.dataforge.meta.string
|
||||||
|
import space.kscience.dataforge.names.Name
|
||||||
|
import space.kscience.dataforge.names.parseAsName
|
||||||
|
import space.kscience.dataforge.workspace.WorkspacePlugin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a Snark workspace plugin.
|
||||||
|
*/
|
||||||
|
public class Snark : WorkspacePlugin() {
|
||||||
|
public val io: IOPlugin by require(IOPlugin)
|
||||||
|
override val tag: PluginTag get() = Companion.tag
|
||||||
|
|
||||||
|
public val readers: Map<Name, SnarkReader<Any>> by lazy {
|
||||||
|
context.gather<SnarkReader<Any>>(SnarkReader.DF_TYPE, inherit = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A lazy-initialized map of `TextProcessor` instances used for page-based text transformation.
|
||||||
|
*
|
||||||
|
* @property textProcessors The `TextProcessor` instances accessible by their names.
|
||||||
|
*/
|
||||||
|
public val textProcessors: Map<Name, TextProcessor> by lazy {
|
||||||
|
context.gather(TextProcessor.DF_TYPE, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun preprocessor(transformationMeta: Meta): TextProcessor {
|
||||||
|
val transformationName = transformationMeta.string
|
||||||
|
?: transformationMeta["name"].string ?: error("Transformation name not defined in $transformationMeta")
|
||||||
|
return textProcessors[transformationName.parseAsName()]
|
||||||
|
?: error("Text transformation with name $transformationName not found in $this")
|
||||||
|
}
|
||||||
|
|
||||||
|
public companion object : PluginFactory<Snark> {
|
||||||
|
override val tag: PluginTag = PluginTag("snark")
|
||||||
|
|
||||||
|
override fun build(context: Context, meta: Meta): Snark = Snark()
|
||||||
|
|
||||||
|
private val byteArrayIOReader = IOReader {
|
||||||
|
readByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val byteArraySnarkParser = SnarkReader(byteArrayIOReader)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -1,56 +0,0 @@
|
|||||||
package space.kscience.snark
|
|
||||||
|
|
||||||
import io.ktor.utils.io.core.Input
|
|
||||||
import io.ktor.utils.io.core.readBytes
|
|
||||||
import space.kscience.dataforge.context.Context
|
|
||||||
import space.kscience.dataforge.io.IOReader
|
|
||||||
import space.kscience.dataforge.io.asBinary
|
|
||||||
import space.kscience.dataforge.io.readWith
|
|
||||||
import space.kscience.dataforge.meta.Meta
|
|
||||||
import space.kscience.dataforge.misc.Type
|
|
||||||
import kotlin.reflect.KType
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A parser of binary content including priority flag and file extensions
|
|
||||||
*/
|
|
||||||
@Type(SnarkParser.TYPE)
|
|
||||||
public interface SnarkParser<out R> {
|
|
||||||
public val type: KType
|
|
||||||
|
|
||||||
public val fileExtensions: Set<String>
|
|
||||||
|
|
||||||
public val priority: Int get() = DEFAULT_PRIORITY
|
|
||||||
|
|
||||||
//TODO use Binary instead of ByteArray
|
|
||||||
public fun parse(context: Context, meta: Meta, bytes: ByteArray): R
|
|
||||||
|
|
||||||
public fun asReader(context: Context, meta: Meta): IOReader<R> = object : IOReader<R> {
|
|
||||||
override val type: KType get() = this@SnarkParser.type
|
|
||||||
|
|
||||||
override fun readObject(input: Input): R = parse(context, meta, input.readBytes())
|
|
||||||
}
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
public const val TYPE: String = "snark.parser"
|
|
||||||
public const val DEFAULT_PRIORITY: Int = 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PublishedApi
|
|
||||||
internal class SnarkParserWrapper<R : Any>(
|
|
||||||
val reader: IOReader<R>,
|
|
||||||
override val type: KType,
|
|
||||||
override val fileExtensions: Set<String>,
|
|
||||||
) : SnarkParser<R> {
|
|
||||||
override fun parse(context: Context, meta: Meta, bytes: ByteArray): R = bytes.asBinary().readWith(reader)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a generic parser from reader
|
|
||||||
*/
|
|
||||||
@Suppress("FunctionName")
|
|
||||||
public inline fun <reified R : Any> SnarkParser(
|
|
||||||
reader: IOReader<R>,
|
|
||||||
vararg fileExtensions: String,
|
|
||||||
): SnarkParser<R> = SnarkParserWrapper(reader, typeOf<R>(), fileExtensions.toSet())
|
|
@ -0,0 +1,43 @@
|
|||||||
|
package space.kscience.snark
|
||||||
|
|
||||||
|
import space.kscience.dataforge.io.IOReader
|
||||||
|
import space.kscience.dataforge.io.asBinary
|
||||||
|
import space.kscience.dataforge.misc.DfType
|
||||||
|
import space.kscience.snark.SnarkReader.Companion.DEFAULT_PRIORITY
|
||||||
|
import space.kscience.snark.SnarkReader.Companion.DF_TYPE
|
||||||
|
|
||||||
|
@DfType(DF_TYPE)
|
||||||
|
public interface SnarkReader<out T> : IOReader<T> {
|
||||||
|
public val types: Set<String>
|
||||||
|
public val priority: Int get() = DEFAULT_PRIORITY
|
||||||
|
public fun readFrom(source: String): T
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
public const val DF_TYPE: String = "snark.reader"
|
||||||
|
public const val DEFAULT_PRIORITY: Int = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper class for IOReader that adds priority and MIME type handling.
|
||||||
|
*
|
||||||
|
* @param T The type of data to be read by the IOReader.
|
||||||
|
* @property reader The underlying IOReader instance used for reading data.
|
||||||
|
* @property types The set of supported types that can be read by the SnarkIOReader.
|
||||||
|
* @property priority The priority of the SnarkIOReader. Higher priority SnarkIOReader instances will be preferred over lower priority ones.
|
||||||
|
*/
|
||||||
|
|
||||||
|
private class SnarkReaderWrapper<out T>(
|
||||||
|
private val reader: IOReader<T>,
|
||||||
|
override val types: Set<String>,
|
||||||
|
override val priority: Int = DEFAULT_PRIORITY,
|
||||||
|
) : IOReader<T> by reader, SnarkReader<T> {
|
||||||
|
|
||||||
|
override fun readFrom(source: String): T = readFrom(source.encodeToByteArray().asBinary())
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun <T : Any> SnarkReader(
|
||||||
|
reader: IOReader<T>,
|
||||||
|
vararg types: String,
|
||||||
|
priority: Int = DEFAULT_PRIORITY,
|
||||||
|
): SnarkReader<T> = SnarkReaderWrapper(reader, types.toSet(), priority)
|
@ -0,0 +1,21 @@
|
|||||||
|
package space.kscience.snark
|
||||||
|
|
||||||
|
import space.kscience.dataforge.misc.DfType
|
||||||
|
import space.kscience.dataforge.names.NameToken
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An object that conducts page-based text transformation. Like using link replacement or templating.
|
||||||
|
*/
|
||||||
|
@DfType(TextProcessor.DF_TYPE)
|
||||||
|
public fun interface TextProcessor {
|
||||||
|
|
||||||
|
public fun process(text: CharSequence): String
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
public const val DF_TYPE: String = "snark.textTransformation"
|
||||||
|
public val TEXT_TRANSFORMATION_KEY: NameToken = NameToken("transformation")
|
||||||
|
public val TEXT_PREPROCESSOR_KEY: NameToken = NameToken("preprocessor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
|||||||
|
package space.kscience.snark
|
||||||
|
|
||||||
|
import kotlinx.io.Source
|
||||||
|
import kotlinx.io.asInputStream
|
||||||
|
import space.kscience.dataforge.io.IOReader
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ImageIOReader class is an implementation of the IOReader interface specifically for reading images using the ImageIO library.
|
||||||
|
* It reads the image data from a given source and returns a BufferedImage object.
|
||||||
|
*
|
||||||
|
* @property type The KType of the data to be read by the ImageIOReader.
|
||||||
|
*/
|
||||||
|
public object ImageIOReader : IOReader<BufferedImage> {
|
||||||
|
override fun readFrom(source: Source): BufferedImage = ImageIO.read(source.asInputStream())
|
||||||
|
}
|
@ -0,0 +1,58 @@
|
|||||||
|
@file:OptIn(DFExperimental::class)
|
||||||
|
|
||||||
|
package space.kscience.snark
|
||||||
|
|
||||||
|
import space.kscience.dataforge.data.branch
|
||||||
|
import space.kscience.dataforge.meta.*
|
||||||
|
import space.kscience.dataforge.misc.DFExperimental
|
||||||
|
import space.kscience.dataforge.names.parseAsName
|
||||||
|
import space.kscience.dataforge.workspace.Workspace
|
||||||
|
import space.kscience.dataforge.workspace.WorkspaceBuilder
|
||||||
|
import space.kscience.dataforge.workspace.directory
|
||||||
|
import space.kscience.dataforge.workspace.resources
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
|
||||||
|
//
|
||||||
|
///**
|
||||||
|
// * Reads the specified resources and returns a [DataTree] containing the data.
|
||||||
|
// *
|
||||||
|
// * @param resources The names of the resources to read.
|
||||||
|
// * @param classLoader The class loader to use for loading the resources. By default, it uses the current thread's context class loader.
|
||||||
|
// * @return A DataTree containing the data read from the resources.
|
||||||
|
// */
|
||||||
|
//private fun IOPlugin.readResources(
|
||||||
|
// vararg resources: String,
|
||||||
|
// classLoader: ClassLoader = Thread.currentThread().contextClassLoader,
|
||||||
|
//): DataTree<Binary> = DataTree {
|
||||||
|
// // require(resource.isNotBlank()) {"Can't mount root resource tree as data root"}
|
||||||
|
// resources.forEach { resource ->
|
||||||
|
// val path = classLoader.getResource(resource)?.toURI()?.toPath() ?: error(
|
||||||
|
// "Resource with name $resource is not resolved"
|
||||||
|
// )
|
||||||
|
// node(resource, readRawDirectory(path))
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
public fun Snark.workspace(
|
||||||
|
meta: Meta,
|
||||||
|
workspaceBuilder: WorkspaceBuilder.() -> Unit = {},
|
||||||
|
): Workspace = Workspace {
|
||||||
|
|
||||||
|
|
||||||
|
data {
|
||||||
|
meta.getIndexed("directory").forEach { (index, directoryMeta) ->
|
||||||
|
val dataDirectory = directoryMeta["path"].string ?: error("Directory path not defined")
|
||||||
|
val nodeName = directoryMeta["name"].string ?: directoryMeta.string ?: index ?: ""
|
||||||
|
directory(io, nodeName.parseAsName(), Path((dataDirectory)))
|
||||||
|
}
|
||||||
|
meta.getIndexed("resource").forEach { (index, resourceMeta) ->
|
||||||
|
val resource = resourceMeta["path"]?.stringList ?: listOf("/")
|
||||||
|
val nodeName = resourceMeta["name"].string ?: resourceMeta.string ?: index ?: ""
|
||||||
|
branch(nodeName) {
|
||||||
|
resources(io, *resource.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaceBuilder()
|
||||||
|
}
|
@ -29,11 +29,13 @@ public class SnarkGradlePlugin : Plugin<Project> {
|
|||||||
|
|
||||||
plugins.withId("org.jetbrains.kotlin.jvm") {
|
plugins.withId("org.jetbrains.kotlin.jvm") {
|
||||||
val writeBuildDate = tasks.register("writeBuildDate") {
|
val writeBuildDate = tasks.register("writeBuildDate") {
|
||||||
val outputFile = File(project.buildDir, "resources/main/buildDate")
|
val outputFile = project.layout.buildDirectory.file("resources/main/buildDate")
|
||||||
doLast {
|
doLast {
|
||||||
val deployDate = LocalDateTime.now()
|
val deployDate = LocalDateTime.now()
|
||||||
outputFile.parentFile.mkdirs()
|
outputFile.get().asFile.run {
|
||||||
outputFile.writeText(deployDate.toString())
|
parentFile.mkdirs()
|
||||||
|
writeText(deployDate.toString())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
outputs.file(outputFile)
|
outputs.file(outputFile)
|
||||||
outputs.upToDateWhen { false }
|
outputs.upToDateWhen { false }
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("space.kscience.gradle.jvm")
|
id("space.kscience.gradle.mpp")
|
||||||
`maven-publish`
|
`maven-publish`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7,21 +7,22 @@ val dataforgeVersion: String by rootProject.extra
|
|||||||
val ktorVersion = space.kscience.gradle.KScienceVersions.ktorVersion
|
val ktorVersion = space.kscience.gradle.KScienceVersions.ktorVersion
|
||||||
|
|
||||||
kscience{
|
kscience{
|
||||||
|
jvm()
|
||||||
useContextReceivers()
|
useContextReceivers()
|
||||||
}
|
commonMain{
|
||||||
|
|
||||||
dependencies {
|
|
||||||
api(projects.snarkCore)
|
api(projects.snarkCore)
|
||||||
|
|
||||||
api("org.jetbrains.kotlinx:kotlinx-html:0.8.0")
|
api(spclibs.kotlinx.html)
|
||||||
api("org.jetbrains.kotlin-wrappers:kotlin-css")
|
api("org.jetbrains.kotlin-wrappers:kotlin-css")
|
||||||
|
|
||||||
api("io.ktor:ktor-utils:$ktorVersion")
|
api("io.ktor:ktor-http:$ktorVersion")
|
||||||
|
|
||||||
api("space.kscience:dataforge-io-yaml:$dataforgeVersion")
|
api("space.kscience:dataforge-io-yaml:$dataforgeVersion")
|
||||||
api("org.jetbrains:markdown:0.4.0")
|
api("org.jetbrains:markdown:0.6.1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
readme {
|
readme {
|
||||||
maturity = space.kscience.gradle.Maturity.EXPERIMENTAL
|
maturity = space.kscience.gradle.Maturity.EXPERIMENTAL
|
||||||
feature("data") { "Data-based processing. Instead of traditional layout-based" }
|
feature("data") { "Data-based processing. Instead of traditional layout-based" }
|
||||||
|
@ -0,0 +1,67 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import kotlinx.html.HTML
|
||||||
|
import kotlinx.html.stream.createHTML
|
||||||
|
import kotlinx.html.visitTagAndFinalize
|
||||||
|
import space.kscience.dataforge.data.DataSink
|
||||||
|
import space.kscience.dataforge.data.DataTree
|
||||||
|
import space.kscience.dataforge.data.wrap
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.names.Name
|
||||||
|
|
||||||
|
public fun interface HtmlPage {
|
||||||
|
|
||||||
|
context(PageContextWithData, HTML)
|
||||||
|
public fun renderPage()
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
public fun createHtmlString(
|
||||||
|
pageContext: PageContext,
|
||||||
|
dataSet: DataTree<*>?,
|
||||||
|
page: HtmlPage,
|
||||||
|
): String = createHTML().run {
|
||||||
|
HTML(kotlinx.html.emptyMap, this, null).visitTagAndFinalize(this) {
|
||||||
|
with(PageContextWithData(pageContext, dataSet ?: DataTree.EMPTY)) {
|
||||||
|
with(page) {
|
||||||
|
renderPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// data builders
|
||||||
|
|
||||||
|
public fun DataSink<Any>.page(
|
||||||
|
name: Name,
|
||||||
|
pageMeta: Meta = Meta.EMPTY,
|
||||||
|
block: context(PageContextWithData) HTML.() -> Unit,
|
||||||
|
) {
|
||||||
|
val page = HtmlPage(block)
|
||||||
|
wrap<HtmlPage>(name, page, pageMeta)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// if (data.type == typeOf<HtmlData>()) {
|
||||||
|
// val languageMeta: Meta = Language.forName(name)
|
||||||
|
//
|
||||||
|
// val dataMeta: Meta = if (languageMeta.isEmpty()) {
|
||||||
|
// data.meta
|
||||||
|
// } else {
|
||||||
|
// data.meta.toMutableMeta().apply {
|
||||||
|
// "languages" put languageMeta
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// page(name, dataMeta) { pageContext->
|
||||||
|
// head {
|
||||||
|
// title = dataMeta["title"].string ?: "Untitled page"
|
||||||
|
// }
|
||||||
|
// body {
|
||||||
|
// @Suppress("UNCHECKED_CAST")
|
||||||
|
// htmlData(pageContext, data as HtmlData)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
@ -0,0 +1,37 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import space.kscience.dataforge.data.DataSink
|
||||||
|
import space.kscience.dataforge.data.DataTree
|
||||||
|
import space.kscience.dataforge.data.wrap
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.getIndexed
|
||||||
|
import space.kscience.dataforge.meta.string
|
||||||
|
import space.kscience.dataforge.names.Name
|
||||||
|
import space.kscience.dataforge.names.asName
|
||||||
|
import space.kscience.dataforge.names.parseAsName
|
||||||
|
|
||||||
|
public fun interface HtmlSite {
|
||||||
|
context(SiteContextWithData)
|
||||||
|
public fun renderSite()
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun DataSink<Any>.site(
|
||||||
|
name: Name,
|
||||||
|
siteMeta: Meta,
|
||||||
|
block: (siteContext: SiteContext, data: DataTree<*>?) -> Unit,
|
||||||
|
) {
|
||||||
|
wrap(name, HtmlSite { block(site, siteData) }, siteMeta)
|
||||||
|
}
|
||||||
|
|
||||||
|
//public fun DataSetBuilder<Any>.site(name: Name, block: DataSetBuilder<Any>.() -> Unit) {
|
||||||
|
// node(name, block)
|
||||||
|
//}
|
||||||
|
|
||||||
|
internal fun DataSink<Any>.assetsFrom(rootMeta: Meta) {
|
||||||
|
rootMeta.getIndexed("file".asName()).forEach { (_, meta) ->
|
||||||
|
val webName: String? by meta.string()
|
||||||
|
val name by meta.string { error("File path is not provided") }
|
||||||
|
val fileName = name.parseAsName()
|
||||||
|
wrap(fileName, webName?.parseAsName() ?: fileName)
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,197 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import space.kscience.dataforge.actions.AbstractAction
|
||||||
|
import space.kscience.dataforge.actions.transform
|
||||||
|
import space.kscience.dataforge.data.*
|
||||||
|
import space.kscience.dataforge.meta.*
|
||||||
|
import space.kscience.dataforge.names.*
|
||||||
|
import space.kscience.snark.DataTreeWithDefault
|
||||||
|
import space.kscience.snark.SnarkBuilder
|
||||||
|
import space.kscience.snark.html.Language.Companion.SITE_LANGUAGE_KEY
|
||||||
|
import space.kscience.snark.html.Language.Companion.SITE_LANGUAGE_MAP_KEY
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
|
|
||||||
|
public class Language : Scheme() {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Language key override
|
||||||
|
*/
|
||||||
|
public var key: String by string { error("Language key is not defined") }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data location
|
||||||
|
*/
|
||||||
|
public var dataPath: Name by value<Name>(
|
||||||
|
reader = { (it?.string ?: key).parseAsName(true) },
|
||||||
|
writer = { it.toString().asValue() }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page name prefix override
|
||||||
|
*/
|
||||||
|
public var route: Name by value<Name>(
|
||||||
|
reader = { (it?.string ?: key).parseAsName(true) },
|
||||||
|
writer = { it.toString().asValue() }
|
||||||
|
)
|
||||||
|
|
||||||
|
// /**
|
||||||
|
// * An override for data path. By default uses [prefix]
|
||||||
|
// */
|
||||||
|
// public var dataPath: String? by string()
|
||||||
|
//
|
||||||
|
// /**
|
||||||
|
// * Target page name with a given language key
|
||||||
|
// */
|
||||||
|
// public var target: Name?
|
||||||
|
// get() = meta["target"].string?.parseAsName(false)
|
||||||
|
// set(value) {
|
||||||
|
// meta["target"] = value?.toString()?.asValue()
|
||||||
|
// }
|
||||||
|
|
||||||
|
public companion object : SchemeSpec<Language>(::Language) {
|
||||||
|
|
||||||
|
public val LANGUAGE_KEY: Name = "language".asName()
|
||||||
|
|
||||||
|
public val LANGUAGE_MAP_KEY: Name = "languageMap".asName()
|
||||||
|
|
||||||
|
public val SITE_LANGUAGE_KEY: Name = SiteContext.SITE_META_KEY + LANGUAGE_KEY
|
||||||
|
|
||||||
|
public val SITE_LANGUAGE_MAP_KEY: Name = SiteContext.SITE_META_KEY + LANGUAGE_MAP_KEY
|
||||||
|
|
||||||
|
public const val DEFAULT_LANGUAGE: String = "en"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun Language(
|
||||||
|
key: String,
|
||||||
|
route: Name = key.parseAsName(true),
|
||||||
|
modifier: Language.() -> Unit = {},
|
||||||
|
): Language = Language {
|
||||||
|
this.key = key
|
||||||
|
this.route = route
|
||||||
|
modifier()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val Data<*>.language: String?
|
||||||
|
get() = meta[Language.LANGUAGE_KEY].string?.lowercase()
|
||||||
|
|
||||||
|
public val SiteContext.languageMap: Map<String, Language>
|
||||||
|
get() = siteMeta[SITE_LANGUAGE_MAP_KEY]?.items?.map {
|
||||||
|
it.key.toStringUnescaped() to Language.read(it.value)
|
||||||
|
}?.toMap() ?: emptyMap()
|
||||||
|
|
||||||
|
public val SiteContext.language: String
|
||||||
|
get() = siteMeta[SITE_LANGUAGE_KEY].string ?: Language.DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the data tree depth-first
|
||||||
|
*/
|
||||||
|
private fun <T, TR : GenericDataTree<T, TR>> TR.walk(
|
||||||
|
namePrefix: Name = Name.EMPTY,
|
||||||
|
): Sequence<Pair<Name, TR>> = sequence {
|
||||||
|
yield(namePrefix to this@walk)
|
||||||
|
items.forEach { (token, tree) ->
|
||||||
|
yieldAll(tree.walk(namePrefix + token))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private class LanguageMapAction(val languages: Set<Language>) : AbstractAction<Any, Any>(typeOf<Any>()) {
|
||||||
|
override fun DataSink<Any>.generate(data: DataTree<Any>, meta: Meta) {
|
||||||
|
val languageMapCache = mutableMapOf<Name, MutableMap<Language, DataTree<Any>>>()
|
||||||
|
|
||||||
|
data.walk().forEach { (name, node) ->
|
||||||
|
val language = node.data?.language?.let { itemLanguage -> languages.find { it.key == itemLanguage } }
|
||||||
|
if (language == null) {
|
||||||
|
// put data without a language into all buckets
|
||||||
|
languageMapCache[name] = languages.associateWithTo(HashMap()) { node }
|
||||||
|
} else {
|
||||||
|
// collect data with language markers
|
||||||
|
val nameWithoutPrefix = if (name.startsWith(language.dataPath)) name.cutFirst() else name
|
||||||
|
languageMapCache.getOrPut(nameWithoutPrefix) { mutableMapOf() }[language] = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
languageMapCache.forEach { (nodeName, languageMap) ->
|
||||||
|
val languageMapMeta = Meta {
|
||||||
|
languageMap.keys.forEach { language ->
|
||||||
|
set(language.key, (language.route + nodeName).toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
languageMap.forEach { (language, node) ->
|
||||||
|
val languagePrefix = language.dataPath
|
||||||
|
val nodeData = node.data
|
||||||
|
if (nodeData != null) {
|
||||||
|
put(
|
||||||
|
languagePrefix + nodeName,
|
||||||
|
nodeData.withMeta { set(Language.LANGUAGE_MAP_KEY, languageMapMeta) }
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
wrap(languagePrefix + nodeName, Unit, Meta { set(Language.LANGUAGE_MAP_KEY, languageMapMeta) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun DataSink<Any>.update(source: DataTree<Any>, meta: Meta, namedData: NamedData<Any>) {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a multiple sites for different languages. All sites use the same [content], but rely on different data
|
||||||
|
*/
|
||||||
|
@SnarkBuilder
|
||||||
|
public fun SiteContextWithData.multiLanguageSite(
|
||||||
|
defaultLanguage: Language,
|
||||||
|
vararg languages: Language,
|
||||||
|
content: HtmlSite,
|
||||||
|
) {
|
||||||
|
val languageSet = setOf(defaultLanguage, *languages)
|
||||||
|
|
||||||
|
val languageMappedData = siteData.filterByType<Any>().transform(
|
||||||
|
LanguageMapAction(languageSet)
|
||||||
|
)
|
||||||
|
|
||||||
|
languageSet.forEach { language ->
|
||||||
|
val languageSiteMeta = Meta {
|
||||||
|
SITE_LANGUAGE_KEY put language.key
|
||||||
|
SITE_LANGUAGE_MAP_KEY put Meta {
|
||||||
|
languageSet.forEach {
|
||||||
|
it.key put it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val overlayData = DataTreeWithDefault<Any>(
|
||||||
|
languageMappedData.branch(language.dataPath)!!,
|
||||||
|
languageMappedData.branch(defaultLanguage.dataPath)!!
|
||||||
|
)
|
||||||
|
|
||||||
|
site(
|
||||||
|
language.route,
|
||||||
|
overlayData,
|
||||||
|
siteMeta = Laminate(languageSiteMeta, siteMeta),
|
||||||
|
content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The language key of this page
|
||||||
|
*/
|
||||||
|
public val PageContext.language: String
|
||||||
|
get() = pageMeta[Language.LANGUAGE_KEY]?.string ?: pageMeta[SITE_LANGUAGE_KEY]?.string ?: Language.DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mapping of language keys to other language versions of this page
|
||||||
|
*/
|
||||||
|
public val PageContext.languageMap: Map<String, Meta>
|
||||||
|
get() = pageMeta[Language.LANGUAGE_MAP_KEY]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap()
|
||||||
|
|
||||||
|
public fun PageContext.localisedPageRef(pageName: Name): String {
|
||||||
|
val prefix = languageMap[language]?.get(Language::dataPath.name)?.string?.parseAsName() ?: Name.EMPTY
|
||||||
|
return resolvePageRef(prefix + pageName)
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import space.kscience.dataforge.data.Data
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.MutableMeta
|
||||||
|
import space.kscience.dataforge.meta.copy
|
||||||
|
|
||||||
|
|
||||||
|
private class MetaMaskData<T>(val origin: Data<T>, override val meta: Meta) : Data<T> by origin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A data with overriden meta. It reflects original data computed state.
|
||||||
|
*/
|
||||||
|
public fun <T> Data<T>.withMeta(newMeta: Meta): Data<T> = if (this is MetaMaskData) {
|
||||||
|
MetaMaskData(origin, newMeta)
|
||||||
|
} else {
|
||||||
|
MetaMaskData(this, newMeta)
|
||||||
|
}
|
||||||
|
|
||||||
|
public inline fun <T> Data<T>.withMeta(block: MutableMeta.() -> Unit): Data<T> = withMeta(meta.copy(block))
|
@ -0,0 +1,80 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import io.ktor.http.URLBuilder
|
||||||
|
import io.ktor.http.Url
|
||||||
|
import io.ktor.http.appendPathSegments
|
||||||
|
import space.kscience.dataforge.data.DataTree
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.get
|
||||||
|
import space.kscience.dataforge.meta.string
|
||||||
|
import space.kscience.dataforge.names.*
|
||||||
|
import space.kscience.snark.SnarkBuilder
|
||||||
|
import space.kscience.snark.SnarkContext
|
||||||
|
|
||||||
|
public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") {
|
||||||
|
if (it.hasIndex()) {
|
||||||
|
"${it.body}[${it.index}]"
|
||||||
|
} else {
|
||||||
|
it.body
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A context for building a single page
|
||||||
|
*/
|
||||||
|
@SnarkBuilder
|
||||||
|
public interface PageContext : SnarkContext {
|
||||||
|
|
||||||
|
public val site: SiteContext
|
||||||
|
|
||||||
|
public val host: Url
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A metadata for a page. It should include site metadata
|
||||||
|
*/
|
||||||
|
public val pageMeta: Meta
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A route relative to parent site. Includes [SiteContext.siteRoute].
|
||||||
|
*/
|
||||||
|
public val pageRoute: Name
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve absolute url for given relative [ref]
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public fun resolveRef(ref: String, targetSite: SiteContext = site): String {
|
||||||
|
val pageUrl = URLBuilder(host)
|
||||||
|
.appendPathSegments(targetSite.path, true)
|
||||||
|
.appendPathSegments(ref)
|
||||||
|
|
||||||
|
return pageUrl.buildString().removeSuffix("/")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve absolute url for a page with given [pageName].
|
||||||
|
*
|
||||||
|
* @param relative if true, add [SiteContext] route to the absolute page name
|
||||||
|
*/
|
||||||
|
public fun resolvePageRef(pageName: Name, targetSite: SiteContext = site): String
|
||||||
|
|
||||||
|
public fun resolveRelativePageRef(pageName: Name): String = resolvePageRef(pageRoute + pageName)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
context(PageContext)
|
||||||
|
public val page: PageContext
|
||||||
|
get() = this@PageContext
|
||||||
|
|
||||||
|
public fun PageContext.resolvePageRef(pageName: String, targetSite: SiteContext = site): String =
|
||||||
|
resolvePageRef(pageName.parseAsName(), targetSite)
|
||||||
|
|
||||||
|
public val PageContext.homeRef: String get() = resolvePageRef(Name.EMPTY)
|
||||||
|
|
||||||
|
public val PageContext.name: Name? get() = pageMeta["name"].string?.parseAsName()
|
||||||
|
|
||||||
|
|
||||||
|
public class PageContextWithData(
|
||||||
|
private val pageContext: PageContext,
|
||||||
|
public val data: DataTree<*>,
|
||||||
|
) : PageContext by pageContext
|
@ -0,0 +1,85 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.html.FlowContent
|
||||||
|
import space.kscience.dataforge.data.*
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.get
|
||||||
|
import space.kscience.dataforge.meta.int
|
||||||
|
import space.kscience.dataforge.meta.string
|
||||||
|
import space.kscience.dataforge.names.Name
|
||||||
|
import space.kscience.dataforge.names.parseAsName
|
||||||
|
import space.kscience.dataforge.names.plus
|
||||||
|
import space.kscience.dataforge.names.startsWith
|
||||||
|
import space.kscience.snark.SnarkContext
|
||||||
|
|
||||||
|
public fun interface PageFragment {
|
||||||
|
|
||||||
|
context(PageContextWithData, FlowContent) public fun renderFragment()
|
||||||
|
}
|
||||||
|
|
||||||
|
context(PageContextWithData, FlowContent)
|
||||||
|
public fun fragment(fragment: PageFragment): Unit {
|
||||||
|
with(fragment) {
|
||||||
|
renderFragment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
context(PageContextWithData, FlowContent)
|
||||||
|
public fun fragment(data: Data<PageFragment>): Unit = runBlocking {
|
||||||
|
fragment(data.await())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
context(SnarkContext)
|
||||||
|
public val Data<*>.id: String
|
||||||
|
get() = meta["id"]?.string ?: "block[${hashCode()}]"
|
||||||
|
|
||||||
|
context(SnarkContext)
|
||||||
|
public val Data<*>.order: Int?
|
||||||
|
get() = meta["order"]?.int
|
||||||
|
|
||||||
|
context(SnarkContext)
|
||||||
|
public val Data<*>.published: Boolean
|
||||||
|
get() = meta["published"].string != "false"
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a Html builder by its full name
|
||||||
|
*/
|
||||||
|
context(SnarkContext)
|
||||||
|
public fun DataTree<*>.resolveHtmlOrNull(name: Name): Data<PageFragment>? {
|
||||||
|
val resolved = (getByType<PageFragment>(name) ?: getByType<PageFragment>(name + SiteContext.INDEX_PAGE_TOKEN))
|
||||||
|
|
||||||
|
return resolved?.takeIf {
|
||||||
|
it.published //TODO add language confirmation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context(SnarkContext)
|
||||||
|
public fun DataTree<*>.resolveHtmlOrNull(name: String): Data<PageFragment>? = resolveHtmlOrNull(name.parseAsName())
|
||||||
|
|
||||||
|
context(SnarkContext)
|
||||||
|
public fun DataTree<*>.resolveHtml(name: String): Data<PageFragment> = resolveHtmlOrNull(name)
|
||||||
|
?: error("Html fragment with name $name is not resolved")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all Html blocks using given name/meta filter
|
||||||
|
*/
|
||||||
|
context(SnarkContext)
|
||||||
|
public fun DataTree<*>.resolveAllHtml(
|
||||||
|
predicate: (name: Name, meta: Meta) -> Boolean,
|
||||||
|
): Map<Name, Data<PageFragment>> = filterByType<PageFragment> { name, meta, _ ->
|
||||||
|
predicate(name, meta)
|
||||||
|
&& meta["published"].string != "false"
|
||||||
|
//TODO add language confirmation
|
||||||
|
}.asSequence().associate { it.name to it.data }
|
||||||
|
|
||||||
|
context(SnarkContext)
|
||||||
|
public fun DataTree<*>.findHtmlByContentType(
|
||||||
|
contentType: String,
|
||||||
|
baseName: Name = Name.EMPTY,
|
||||||
|
): Map<Name, Data<PageFragment>> = resolveAllHtml { name, meta ->
|
||||||
|
name.startsWith(baseName) && meta["content_type"].string == contentType
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import space.kscience.dataforge.actions.AbstractAction
|
||||||
|
import space.kscience.dataforge.data.*
|
||||||
|
import space.kscience.dataforge.io.Binary
|
||||||
|
import space.kscience.dataforge.io.toByteArray
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.get
|
||||||
|
import space.kscience.snark.TextProcessor
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
|
public class ParseAction(private val snarkHtml: SnarkHtml) :
|
||||||
|
AbstractAction<Binary, PageFragment>(typeOf<PageFragment>()) {
|
||||||
|
|
||||||
|
private fun parseOne(data: NamedData<Binary>): NamedData<PageFragment>? = with(snarkHtml) {
|
||||||
|
val contentType = getContentType(data.name, data.meta)
|
||||||
|
|
||||||
|
val parser = snark.readers.values.filterIsInstance<SnarkHtmlReader>().filter { parser ->
|
||||||
|
contentType in parser.types
|
||||||
|
}.maxByOrNull {
|
||||||
|
it.priority
|
||||||
|
}
|
||||||
|
|
||||||
|
//ignore data for which parser is not found
|
||||||
|
if (parser != null) {
|
||||||
|
val preprocessor = meta[TextProcessor.TEXT_PREPROCESSOR_KEY]?.let { snark.preprocessor(it) }
|
||||||
|
data.transform {
|
||||||
|
if (preprocessor == null) {
|
||||||
|
parser.readFrom(it)
|
||||||
|
} else {
|
||||||
|
//TODO provide encoding
|
||||||
|
val string = it.toByteArray().decodeToString()
|
||||||
|
parser.readFrom(preprocessor.process(string))
|
||||||
|
}
|
||||||
|
}.named(data.name)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun DataSink<PageFragment>.generate(data: DataTree<Binary>, meta: Meta) {
|
||||||
|
data.forEach {
|
||||||
|
parseOne(it)?.let { put(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun DataSink<PageFragment>.update(source: DataTree<Binary>, meta: Meta, namedData: NamedData<Binary>) {
|
||||||
|
parseOne(namedData)?.let { put(it) }
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import kotlinx.html.*
|
||||||
|
import space.kscience.dataforge.meta.string
|
||||||
|
import space.kscience.dataforge.names.parseAsName
|
||||||
|
import space.kscience.snark.TextProcessor
|
||||||
|
|
||||||
|
public class WebPageTextProcessor(private val page: PageContext) : TextProcessor {
|
||||||
|
private val regex = """\$\{([\w.]*)(?>\("(.*)"\))?}""".toRegex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A basic [TextProcessor] that replaces `${...}` expressions in text. The following expressions are recognised:
|
||||||
|
* * `homeRef` resolves to [homeRef]
|
||||||
|
* * `resolveRef("...")` -> [PageContext.resolveRef]
|
||||||
|
* * `resolvePageRef("...")` -> [PageContext.resolvePageRef]
|
||||||
|
* * `pageMeta.get("...") -> [PageContext.pageMeta] get string method
|
||||||
|
* Otherwise return unchanged string
|
||||||
|
*/
|
||||||
|
override fun process(text: CharSequence): String = text.replace(regex) { match ->
|
||||||
|
when (match.groups[1]!!.value) {
|
||||||
|
"homeRef" -> page.homeRef
|
||||||
|
"resolveRef" -> {
|
||||||
|
val refString = match.groups[2]?.value ?: error("resolveRef requires a string (quoted) argument")
|
||||||
|
page.resolveRef(refString)
|
||||||
|
}
|
||||||
|
|
||||||
|
"resolvePageRef" -> {
|
||||||
|
val refString = match.groups[2]?.value
|
||||||
|
?: error("resolvePageRef requires a string (quoted) argument")
|
||||||
|
page.localisedPageRef(refString.parseAsName())
|
||||||
|
}
|
||||||
|
|
||||||
|
"pageMeta.get" -> {
|
||||||
|
val nameString = match.groups[2]?.value
|
||||||
|
?: error("resolvePageRef requires a string (quoted) argument")
|
||||||
|
page.pageMeta[nameString.parseAsName()].string ?: "@null"
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> match.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tag consumer wrapper that wraps existing [TagConsumer] and adds postprocessing.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public class Postprocessor<out R>(
|
||||||
|
public val page: PageContext,
|
||||||
|
private val consumer: TagConsumer<R>,
|
||||||
|
private val processor: TextProcessor = WebPageTextProcessor(page),
|
||||||
|
) : TagConsumer<R> by consumer {
|
||||||
|
|
||||||
|
override fun onTagAttributeChange(tag: Tag, attribute: String, value: String?) {
|
||||||
|
if (tag is A && attribute == "href" && value != null) {
|
||||||
|
consumer.onTagAttributeChange(tag, attribute, processor.process(value))
|
||||||
|
} else {
|
||||||
|
consumer.onTagAttributeChange(tag, attribute, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTagContent(content: CharSequence) {
|
||||||
|
consumer.onTagContent(processor.process(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTagContentUnsafe(block: Unsafe.() -> Unit) {
|
||||||
|
val proxy = object : Unsafe {
|
||||||
|
override fun String.unaryPlus() {
|
||||||
|
consumer.onTagContentUnsafe {
|
||||||
|
processor.process(this@unaryPlus).unaryPlus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
proxy.block()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
context(PageContext)
|
||||||
|
public inline fun FlowContent.postprocess(
|
||||||
|
processor: TextProcessor = WebPageTextProcessor(page),
|
||||||
|
block: FlowContent.() -> Unit,
|
||||||
|
) {
|
||||||
|
val fc = object : FlowContent by this {
|
||||||
|
override val consumer: TagConsumer<*> = Postprocessor(page, this@postprocess.consumer, processor)
|
||||||
|
}
|
||||||
|
fc.block()
|
||||||
|
}
|
@ -0,0 +1,232 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import space.kscience.dataforge.data.*
|
||||||
|
import space.kscience.dataforge.io.Binary
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.get
|
||||||
|
import space.kscience.dataforge.meta.string
|
||||||
|
import space.kscience.dataforge.names.*
|
||||||
|
import space.kscience.snark.SnarkBuilder
|
||||||
|
import space.kscience.snark.SnarkContext
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An abstraction, which is used to render sites to the different rendering engines
|
||||||
|
*/
|
||||||
|
@SnarkBuilder
|
||||||
|
public interface SiteContext : SnarkContext {
|
||||||
|
|
||||||
|
public val parent: SiteContext?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A context path segments for this site
|
||||||
|
*/
|
||||||
|
public val path: List<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route name of this [SiteContext] relative to the site root
|
||||||
|
*/
|
||||||
|
public val siteRoute: Name
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site configuration
|
||||||
|
*/
|
||||||
|
public val siteMeta: Meta
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a static file or resource for the given route and data.
|
||||||
|
*
|
||||||
|
* @param route The route name of the static file relative to the site root.
|
||||||
|
* @param data The data object containing the binary data for the static file.
|
||||||
|
*/
|
||||||
|
public fun static(route: Name, data: Data<Binary>)
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a single page at given [route]. If the route is empty, create an index page the current route.
|
||||||
|
*
|
||||||
|
* @param pageMeta additional page meta. [PageContext] will use both it and [siteMeta]
|
||||||
|
*/
|
||||||
|
@SnarkBuilder
|
||||||
|
public fun page(
|
||||||
|
route: Name,
|
||||||
|
data: DataTree<*>?,
|
||||||
|
pageMeta: Meta = Meta.EMPTY,
|
||||||
|
content: HtmlPage,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a route block with its own data. Does not change the context path
|
||||||
|
*/
|
||||||
|
@SnarkBuilder
|
||||||
|
public fun route(
|
||||||
|
route: Name,
|
||||||
|
data: DataTree<*>?,
|
||||||
|
siteMeta: Meta = Meta.EMPTY,
|
||||||
|
content: HtmlSite,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a sub-site and changes context path to match [name]
|
||||||
|
* @param route mount site at [rootName]
|
||||||
|
*/
|
||||||
|
@SnarkBuilder
|
||||||
|
public fun site(
|
||||||
|
route: Name,
|
||||||
|
data: DataTree<*>?,
|
||||||
|
siteMeta: Meta = Meta.EMPTY,
|
||||||
|
content: HtmlSite,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
public val SITE_META_KEY: Name = "site".asName()
|
||||||
|
public val INDEX_PAGE_TOKEN: NameToken = NameToken("index")
|
||||||
|
public val UP_PAGE_TOKEN: NameToken = NameToken("..")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun SiteContext.static(dataSet: DataTree<Binary>, prefix: Name = Name.EMPTY) {
|
||||||
|
dataSet.forEach { (name, data) ->
|
||||||
|
static(prefix + name, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun SiteContext.static(dataSet: DataTree<*>, branch: String, prefix: String = branch) {
|
||||||
|
val branchName = branch.parseAsName()
|
||||||
|
val prefixName = prefix.parseAsName()
|
||||||
|
dataSet.branch(branchName)?.filterByType<Binary>()?.forEach {
|
||||||
|
static(prefixName + it.name, it.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
context(SiteContext)
|
||||||
|
public val site: SiteContext
|
||||||
|
get() = this@SiteContext
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A wrapper for site context that allows convenient site building experience
|
||||||
|
*/
|
||||||
|
public class SiteContextWithData(private val site: SiteContext, public val siteData: DataTree<*>) : SiteContext by site
|
||||||
|
|
||||||
|
|
||||||
|
@SnarkBuilder
|
||||||
|
public fun SiteContextWithData.static(branch: String, prefix: String = branch): Unit = static(siteData, branch, prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@SnarkBuilder
|
||||||
|
public fun SiteContextWithData.page(
|
||||||
|
route: Name = Name.EMPTY,
|
||||||
|
pageMeta: Meta = Meta.EMPTY,
|
||||||
|
content: HtmlPage,
|
||||||
|
): Unit = page(route, siteData, pageMeta, content)
|
||||||
|
|
||||||
|
@SnarkBuilder
|
||||||
|
public fun SiteContextWithData.route(
|
||||||
|
route: String,
|
||||||
|
data: DataTree<*>? = siteData,
|
||||||
|
siteMeta: Meta = Meta.EMPTY,
|
||||||
|
content: HtmlSite,
|
||||||
|
): Unit = route(route.parseAsName(), data, siteMeta, content)
|
||||||
|
|
||||||
|
@SnarkBuilder
|
||||||
|
public fun SiteContextWithData.site(
|
||||||
|
route: String,
|
||||||
|
data: DataTree<*>? = siteData,
|
||||||
|
siteMeta: Meta = Meta.EMPTY,
|
||||||
|
content: HtmlSite,
|
||||||
|
): Unit = site(route.parseAsName(), data, siteMeta, content)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render all pages and sites found in the data
|
||||||
|
*/
|
||||||
|
public suspend fun SiteContext.renderPages(data: DataTree<*>): Unit {
|
||||||
|
|
||||||
|
// Render all sub-sites
|
||||||
|
data.filterByType<HtmlSite>().forEach { siteData: NamedData<HtmlSite> ->
|
||||||
|
// generate a sub-site context and render the data in sub-site context
|
||||||
|
val dataPrefix = siteData.meta["site.dataPath"].string?.asName() ?: Name.EMPTY
|
||||||
|
site(
|
||||||
|
route = siteData.meta["site.route"].string?.asName() ?: siteData.name,
|
||||||
|
data.branch(dataPrefix) ?: DataTree.EMPTY,
|
||||||
|
siteMeta = siteData.meta,
|
||||||
|
siteData.await()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render all stand-alone pages in default site
|
||||||
|
data.filterByType<HtmlPage>().forEach { pageData: NamedData<HtmlPage> ->
|
||||||
|
val dataPrefix = pageData.meta["page.dataPath"].string?.asName() ?: Name.EMPTY
|
||||||
|
page(
|
||||||
|
route = pageData.meta["page.route"].string?.asName() ?: pageData.name,
|
||||||
|
data.branch(dataPrefix) ?: DataTree.EMPTY,
|
||||||
|
pageMeta = pageData.meta,
|
||||||
|
pageData.await()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
//
|
||||||
|
///**
|
||||||
|
// * Recursively renders the data items in [data]. If [LAYOUT_KEY] is defined in an item, use it to load
|
||||||
|
// * layout from the context, otherwise render children nodes as name segments and individual data items using [dataRenderer].
|
||||||
|
// */
|
||||||
|
//public fun SiteContext.pages(
|
||||||
|
// dataRenderer: DataRenderer = DataRenderer.DEFAULT,
|
||||||
|
//) {
|
||||||
|
// val layoutMeta = siteData().meta[LAYOUT_KEY]
|
||||||
|
// if (layoutMeta != null) {
|
||||||
|
// //use layout if it is defined
|
||||||
|
// snark.siteLayout(layoutMeta).render(siteData())
|
||||||
|
// } else {
|
||||||
|
// when (siteData()) {
|
||||||
|
// is DataTreeItem.Node -> {
|
||||||
|
// siteData().tree.items.forEach { (token, item) ->
|
||||||
|
// //Don't apply index token
|
||||||
|
// if (token == SiteLayout.INDEX_PAGE_TOKEN) {
|
||||||
|
// pages(item, dataRenderer)
|
||||||
|
// } else if (item is DataTreeItem.Leaf) {
|
||||||
|
// dataRenderer(token.asName(), item.data)
|
||||||
|
// } else {
|
||||||
|
// route(token.asName()) {
|
||||||
|
// pages(item, dataRenderer)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// is DataTreeItem.Leaf -> {
|
||||||
|
// dataRenderer(Name.EMPTY, siteData().data)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// siteData().meta[SiteLayout.ASSETS_KEY]?.let {
|
||||||
|
// assetsFrom(it)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// //TODO watch for changes
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
///**
|
||||||
|
// * Render all pages in a node with given name
|
||||||
|
// */
|
||||||
|
//public fun SiteContext.pages(
|
||||||
|
// dataPath: Name,
|
||||||
|
// remotePath: Name = dataPath,
|
||||||
|
// dataRenderer: DataRenderer = DataRenderer.DEFAULT,
|
||||||
|
//) {
|
||||||
|
// val item = resolveData.getItem(dataPath) ?: error("No data found by name $dataPath")
|
||||||
|
// route(remotePath) {
|
||||||
|
// pages(item, dataRenderer)
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
//
|
||||||
|
//public fun SiteContext.pages(
|
||||||
|
// dataPath: String,
|
||||||
|
// remotePath: Name = dataPath.parseAsName(),
|
||||||
|
// dataRenderer: DataRenderer = DataRenderer.DEFAULT,
|
||||||
|
//) {
|
||||||
|
// pages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
|
||||||
|
//}
|
@ -0,0 +1,119 @@
|
|||||||
|
@file:OptIn(DFExperimental::class)
|
||||||
|
|
||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import io.ktor.http.ContentType
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.io.readByteArray
|
||||||
|
import space.kscience.dataforge.actions.Action
|
||||||
|
import space.kscience.dataforge.actions.transform
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.context.PluginFactory
|
||||||
|
import space.kscience.dataforge.context.PluginTag
|
||||||
|
import space.kscience.dataforge.data.*
|
||||||
|
import space.kscience.dataforge.io.Binary
|
||||||
|
import space.kscience.dataforge.io.IOPlugin
|
||||||
|
import space.kscience.dataforge.io.IOReader
|
||||||
|
import space.kscience.dataforge.io.JsonMetaFormat
|
||||||
|
import space.kscience.dataforge.io.yaml.YamlMetaFormat
|
||||||
|
import space.kscience.dataforge.io.yaml.YamlPlugin
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.set
|
||||||
|
import space.kscience.dataforge.meta.string
|
||||||
|
import space.kscience.dataforge.misc.DFExperimental
|
||||||
|
import space.kscience.dataforge.names.Name
|
||||||
|
import space.kscience.dataforge.names.asName
|
||||||
|
import space.kscience.dataforge.provider.dfType
|
||||||
|
import space.kscience.dataforge.workspace.*
|
||||||
|
import space.kscience.snark.ReWrapAction
|
||||||
|
import space.kscience.snark.Snark
|
||||||
|
import space.kscience.snark.SnarkReader
|
||||||
|
import java.net.URLConnection
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
import kotlin.io.path.extension
|
||||||
|
|
||||||
|
|
||||||
|
public fun <T : Any, R : Any> DataTree<T>.transform(action: Action<T, R>, meta: Meta = Meta.EMPTY): DataTree<R> =
|
||||||
|
action.execute(this, meta)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plugin used for rendering a [DataTree] as HTML
|
||||||
|
*/
|
||||||
|
public class SnarkHtml : WorkspacePlugin() {
|
||||||
|
public val snark: Snark by require(Snark)
|
||||||
|
private val yaml by require(YamlPlugin)
|
||||||
|
public val io: IOPlugin get() = snark.io
|
||||||
|
|
||||||
|
override val tag: PluginTag get() = Companion.tag
|
||||||
|
|
||||||
|
override fun content(target: String): Map<Name, Any> = when (target) {
|
||||||
|
SnarkReader::class.dfType -> mapOf(
|
||||||
|
"html".asName() to HtmlReader,
|
||||||
|
"markdown".asName() to MarkdownReader,
|
||||||
|
"json".asName() to SnarkReader(JsonMetaFormat, ContentType.Application.Json.toString()),
|
||||||
|
"yaml".asName() to SnarkReader(YamlMetaFormat, "text/yaml", "yaml"),
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> super.content(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun getContentType(name: Name, meta: Meta): String = meta[CONTENT_TYPE_KEY].string ?: run {
|
||||||
|
val filePath = meta[FileData.FILE_PATH_KEY]?.string ?: name.toString()
|
||||||
|
URLConnection.guessContentTypeFromName(filePath) ?: Path(filePath).extension
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public val prepareHeaderAction: ReWrapAction<Any> = ReWrapAction.removeExtensions<Any>("html", "md") { name ->
|
||||||
|
val contentType = getContentType(name, this)
|
||||||
|
set(CONTENT_TYPE_KEY, contentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
public val removeIndexAction: ReWrapAction<Any> = ReWrapAction.removeIndex<Any>()
|
||||||
|
|
||||||
|
public val parseAction: Action<Binary, PageFragment> = ParseAction(this)
|
||||||
|
private val allDataNotNull: DataSelector<Any>
|
||||||
|
get() = DataSelector { workspace, _ -> workspace.data.filterByType() }
|
||||||
|
|
||||||
|
public val parse: TaskReference<Any> by task<Any>({
|
||||||
|
description = "Parse all data for which reader is resolved"
|
||||||
|
}) {
|
||||||
|
//put all data
|
||||||
|
putAll(from(allDataNotNull))
|
||||||
|
//override parsed data
|
||||||
|
putAll(from(allDataNotNull).filterByType<Binary>().transform(parseAction))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public companion object : PluginFactory<SnarkHtml> {
|
||||||
|
override val tag: PluginTag = PluginTag("snark.html")
|
||||||
|
|
||||||
|
public val CONTENT_TYPE_KEY: Name = "contentType".asName()
|
||||||
|
|
||||||
|
override fun build(context: Context, meta: Meta): SnarkHtml = SnarkHtml()
|
||||||
|
|
||||||
|
private val byteArrayIOReader = IOReader {
|
||||||
|
readByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val byteArraySnarkParser = SnarkReader(byteArrayIOReader)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public fun SnarkHtml.readSiteData(
|
||||||
|
binaries: DataTree<Binary>,
|
||||||
|
meta: Meta = Meta.EMPTY,
|
||||||
|
): DataTree<Any> = ObservableDataTree(context) {
|
||||||
|
//put all binaries
|
||||||
|
putAll(binaries)
|
||||||
|
//override ones which could be parsed
|
||||||
|
putAll(binaries.transform(parseAction, meta))
|
||||||
|
}.transform(prepareHeaderAction, meta).transform(removeIndexAction, meta)
|
||||||
|
|
||||||
|
|
||||||
|
public fun SnarkHtml.readSiteData(
|
||||||
|
coroutineScope: CoroutineScope,
|
||||||
|
meta: Meta = Meta.EMPTY,
|
||||||
|
builder: DataSink<Binary>.() -> Unit,
|
||||||
|
): DataTree<Any> = readSiteData(ObservableDataTree(coroutineScope) { builder() }, meta)
|
@ -0,0 +1,49 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import kotlinx.html.div
|
||||||
|
import kotlinx.html.unsafe
|
||||||
|
import kotlinx.io.Source
|
||||||
|
import kotlinx.io.readString
|
||||||
|
import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor
|
||||||
|
import org.intellij.markdown.html.HtmlGenerator
|
||||||
|
import org.intellij.markdown.parser.MarkdownParser
|
||||||
|
import space.kscience.snark.SnarkReader
|
||||||
|
|
||||||
|
|
||||||
|
public interface SnarkHtmlReader: SnarkReader<PageFragment>
|
||||||
|
|
||||||
|
public object HtmlReader : SnarkHtmlReader {
|
||||||
|
override val types: Set<String> = setOf("html")
|
||||||
|
|
||||||
|
override fun readFrom(source: String): PageFragment = PageFragment {
|
||||||
|
div {
|
||||||
|
unsafe { +source }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readFrom(source: Source): PageFragment = readFrom(source.readString())
|
||||||
|
}
|
||||||
|
|
||||||
|
public object MarkdownReader : SnarkHtmlReader {
|
||||||
|
override val types: Set<String> = setOf("text/markdown", "md", "markdown")
|
||||||
|
|
||||||
|
override fun readFrom(source: String): PageFragment = PageFragment {
|
||||||
|
val parsedTree = markdownParser.buildMarkdownTreeFromString(source)
|
||||||
|
val htmlString = HtmlGenerator(source, parsedTree, markdownFlavor).generateHtml()
|
||||||
|
|
||||||
|
div {
|
||||||
|
unsafe {
|
||||||
|
+htmlString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val markdownFlavor = CommonMarkFlavourDescriptor()
|
||||||
|
private val markdownParser = MarkdownParser(markdownFlavor)
|
||||||
|
|
||||||
|
override fun readFrom(source: Source): PageFragment = readFrom(source.readString())
|
||||||
|
|
||||||
|
public val snarkReader: SnarkReader<PageFragment> = SnarkReader(this, "text/markdown")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,189 @@
|
|||||||
|
package space.kscience.snark.html.static
|
||||||
|
|
||||||
|
import io.ktor.http.Url
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.io.asSink
|
||||||
|
import kotlinx.io.buffered
|
||||||
|
import space.kscience.dataforge.data.*
|
||||||
|
import space.kscience.dataforge.io.Binary
|
||||||
|
import space.kscience.dataforge.io.writeBinary
|
||||||
|
import space.kscience.dataforge.meta.*
|
||||||
|
import space.kscience.dataforge.names.Name
|
||||||
|
import space.kscience.dataforge.names.isEmpty
|
||||||
|
import space.kscience.dataforge.workspace.FileData
|
||||||
|
import space.kscience.snark.html.*
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.*
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of [SiteContext] to render site as a static directory [outputPath]
|
||||||
|
*/
|
||||||
|
internal class StaticSiteContext(
|
||||||
|
override val siteMeta: Meta,
|
||||||
|
private val baseUrl: Url,
|
||||||
|
override val path: List<String>,
|
||||||
|
override val siteRoute: Name,
|
||||||
|
override val parent: SiteContext?,
|
||||||
|
private val outputPath: Path,
|
||||||
|
) : SiteContext {
|
||||||
|
|
||||||
|
|
||||||
|
// @OptIn(ExperimentalPathApi::class)
|
||||||
|
// private suspend fun files(item: DataTreeItem<Any>, routeName: Name) {
|
||||||
|
// //try using direct file rendering
|
||||||
|
// item.meta[FileData.FILE_PATH_KEY]?.string?.let {
|
||||||
|
// val file = Path.of(it)
|
||||||
|
// val targetPath = outputPath.resolve(routeName.toWebPath())
|
||||||
|
// targetPath.parent.createDirectories()
|
||||||
|
// file.copyToRecursively(targetPath, followLinks = false)
|
||||||
|
// //success, don't do anything else
|
||||||
|
// return@files
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// when (item) {
|
||||||
|
// is DataTreeItem.Leaf -> {
|
||||||
|
// val datum = item.data
|
||||||
|
// if (datum.type != typeOf<Binary>()) error("Can't directly serve file of type ${item.data.type}")
|
||||||
|
// val targetPath = outputPath.resolve(routeName.toWebPath())
|
||||||
|
// val binary = datum.await() as Binary
|
||||||
|
// targetPath.outputStream().asSink().buffered().use {
|
||||||
|
// it.writeBinary(binary)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// is DataTreeItem.Node -> {
|
||||||
|
// item.tree.items.forEach { (token, childItem) ->
|
||||||
|
// files(childItem, routeName + token)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPathApi::class)
|
||||||
|
override fun static(route: Name, data: Data<Binary>) {
|
||||||
|
//if data is a file, copy it
|
||||||
|
data.meta[FileData.FILE_PATH_KEY]?.string?.let {
|
||||||
|
val file = Path.of(it)
|
||||||
|
val targetPath = outputPath.resolve(route.toWebPath())
|
||||||
|
targetPath.parent.createDirectories()
|
||||||
|
file.copyToRecursively(targetPath, followLinks = false)
|
||||||
|
//success, don't do anything else
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type != typeOf<Binary>()) error("Can't directly serve file of type ${data.type}")
|
||||||
|
val targetPath = outputPath.resolve(route.toWebPath())
|
||||||
|
runBlocking(Dispatchers.IO) {
|
||||||
|
val binary = data.await()
|
||||||
|
targetPath.outputStream().asSink().buffered().use {
|
||||||
|
it.writeBinary(binary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) {
|
||||||
|
// ref
|
||||||
|
// } else if (ref.isEmpty()) {
|
||||||
|
// baseUrl
|
||||||
|
// } else {
|
||||||
|
// "${baseUrl.removeSuffix("/")}/$ref"
|
||||||
|
// }
|
||||||
|
|
||||||
|
class StaticPageContext(
|
||||||
|
override val site: StaticSiteContext,
|
||||||
|
override val host: Url,
|
||||||
|
override val pageRoute: Name,
|
||||||
|
override val pageMeta: Meta,
|
||||||
|
) : PageContext {
|
||||||
|
|
||||||
|
override fun resolvePageRef(pageName: Name, targetSite: SiteContext): String = resolveRef(
|
||||||
|
pageName.toWebPath() + ".html",
|
||||||
|
targetSite
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun page(route: Name, data: DataTree<*>?, pageMeta: Meta, content: HtmlPage) {
|
||||||
|
|
||||||
|
|
||||||
|
val modifiedPageMeta = pageMeta.toMutableMeta().apply {
|
||||||
|
"name" put route.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val newPath = if (route.isEmpty()) {
|
||||||
|
outputPath.resolve("index.html")
|
||||||
|
} else {
|
||||||
|
outputPath.resolve(route.toWebPath() + ".html")
|
||||||
|
}
|
||||||
|
|
||||||
|
newPath.parent.createDirectories()
|
||||||
|
|
||||||
|
val pageContext = StaticPageContext(this, baseUrl, route, Laminate(modifiedPageMeta, siteMeta))
|
||||||
|
newPath.writeText(HtmlPage.createHtmlString(pageContext, data, content))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun route(route: Name, data: DataTree<*>?, siteMeta: Meta, content: HtmlSite) {
|
||||||
|
val siteContextWithData = SiteContextWithData(
|
||||||
|
StaticSiteContext(
|
||||||
|
siteMeta = Laminate(siteMeta, this@StaticSiteContext.siteMeta),
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = emptyList(),
|
||||||
|
siteRoute = route,
|
||||||
|
parent = parent,
|
||||||
|
outputPath = outputPath.resolve(route.toWebPath())
|
||||||
|
),
|
||||||
|
data ?: DataTree.EMPTY
|
||||||
|
)
|
||||||
|
with(content) {
|
||||||
|
with(siteContextWithData) {
|
||||||
|
renderSite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun site(route: Name, data: DataTree<*>?, siteMeta: Meta, content: HtmlSite) {
|
||||||
|
val siteContextWithData = SiteContextWithData(
|
||||||
|
StaticSiteContext(
|
||||||
|
siteMeta = Laminate(siteMeta, this@StaticSiteContext.siteMeta),
|
||||||
|
baseUrl = baseUrl,
|
||||||
|
path = emptyList(),
|
||||||
|
siteRoute = route,
|
||||||
|
parent = this,
|
||||||
|
outputPath = outputPath.resolve(route.toWebPath())
|
||||||
|
),
|
||||||
|
data ?: DataTree.EMPTY
|
||||||
|
)
|
||||||
|
with(content) {
|
||||||
|
with(siteContextWithData) {
|
||||||
|
renderSite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a static site using given [SnarkEnvironment] in provided [outputPath].
|
||||||
|
* Use [siteUrl] as a base for all resolved URLs. By default, use [outputPath] absolute path as a base.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
@Suppress("UnusedReceiverParameter")
|
||||||
|
public suspend fun SnarkHtml.staticSite(
|
||||||
|
data: DataTree<*>?,
|
||||||
|
outputPath: Path,
|
||||||
|
siteUrl: Url = Url(outputPath.absolutePathString()),
|
||||||
|
siteMeta: Meta = data?.meta ?: Meta.EMPTY,
|
||||||
|
content: HtmlSite,
|
||||||
|
) {
|
||||||
|
val siteContextWithData = SiteContextWithData(
|
||||||
|
StaticSiteContext(siteMeta, siteUrl, emptyList(), Name.EMPTY, null, outputPath),
|
||||||
|
data ?: DataTree.EMPTY
|
||||||
|
)
|
||||||
|
with(content) {
|
||||||
|
with(siteContextWithData) {
|
||||||
|
renderSite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,48 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import kotlinx.html.body
|
|
||||||
import kotlinx.html.head
|
|
||||||
import kotlinx.html.title
|
|
||||||
import space.kscience.dataforge.data.Data
|
|
||||||
import space.kscience.dataforge.meta.*
|
|
||||||
import space.kscience.dataforge.names.Name
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render (or don't) given data piece
|
|
||||||
*/
|
|
||||||
public interface DataRenderer {
|
|
||||||
|
|
||||||
context(SiteBuilder)
|
|
||||||
public operator fun invoke(name: Name, data: Data<Any>)
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
public val DEFAULT: DataRenderer = object : DataRenderer {
|
|
||||||
|
|
||||||
context(SiteBuilder)
|
|
||||||
override fun invoke(name: Name, data: Data<Any>) {
|
|
||||||
if (data.type == typeOf<HtmlData>()) {
|
|
||||||
val languageMeta: Meta = Language.forName(name)
|
|
||||||
|
|
||||||
val dataMeta: Meta = if (languageMeta.isEmpty()) {
|
|
||||||
data.meta
|
|
||||||
} else {
|
|
||||||
data.meta.toMutableMeta().apply {
|
|
||||||
"languages" put languageMeta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
page(name, dataMeta) {
|
|
||||||
head {
|
|
||||||
title = dataMeta["title"].string ?: "Untitled page"
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@Suppress("UNCHECKED_CAST")
|
|
||||||
htmlData(data as HtmlData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,48 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.html.FlowContent
|
|
||||||
import kotlinx.html.TagConsumer
|
|
||||||
import space.kscience.dataforge.data.Data
|
|
||||||
import space.kscience.dataforge.data.await
|
|
||||||
import space.kscience.dataforge.meta.get
|
|
||||||
import space.kscience.dataforge.meta.int
|
|
||||||
import space.kscience.dataforge.meta.string
|
|
||||||
import space.kscience.snark.SnarkContext
|
|
||||||
|
|
||||||
|
|
||||||
//TODO replace by VisionForge type
|
|
||||||
//typealias HtmlFragment = context(PageBuilder, TagConsumer<*>) () -> Unit
|
|
||||||
|
|
||||||
public fun interface HtmlFragment {
|
|
||||||
public fun TagConsumer<*>.renderFragment(page: WebPage)
|
|
||||||
//TODO move pageBuilder to a context receiver after KT-52967 is fixed
|
|
||||||
}
|
|
||||||
|
|
||||||
public typealias HtmlData = Data<HtmlFragment>
|
|
||||||
|
|
||||||
//fun HtmlData(meta: Meta, content: context(PageBuilder) TagConsumer<*>.() -> Unit): HtmlData =
|
|
||||||
// Data(HtmlFragment(content), meta)
|
|
||||||
|
|
||||||
|
|
||||||
context(WebPage)
|
|
||||||
public fun FlowContent.htmlData(data: HtmlData): Unit = runBlocking(Dispatchers.IO) {
|
|
||||||
with(data.await()) { consumer.renderFragment(page) }
|
|
||||||
}
|
|
||||||
|
|
||||||
context(SnarkContext)
|
|
||||||
public val Data<*>.id: String
|
|
||||||
get() = meta["id"]?.string ?: "block[${hashCode()}]"
|
|
||||||
|
|
||||||
context(SnarkContext)
|
|
||||||
public val Data<*>.language: String?
|
|
||||||
get() = meta["language"].string?.lowercase()
|
|
||||||
|
|
||||||
context(SnarkContext)
|
|
||||||
public val Data<*>.order: Int?
|
|
||||||
get() = meta["order"]?.int
|
|
||||||
|
|
||||||
context(SnarkContext)
|
|
||||||
public val Data<*>.published: Boolean
|
|
||||||
get() = meta["published"].string != "false"
|
|
@ -1,15 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import io.ktor.util.asStream
|
|
||||||
import io.ktor.utils.io.core.Input
|
|
||||||
import space.kscience.dataforge.io.IOReader
|
|
||||||
import java.awt.image.BufferedImage
|
|
||||||
import javax.imageio.ImageIO
|
|
||||||
import kotlin.reflect.KType
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
internal object ImageIOReader : IOReader<BufferedImage> {
|
|
||||||
override val type: KType get() = typeOf<BufferedImage>()
|
|
||||||
|
|
||||||
override fun readObject(input: Input): BufferedImage = ImageIO.read(input.asStream())
|
|
||||||
}
|
|
@ -1,149 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import space.kscience.dataforge.data.getItem
|
|
||||||
import space.kscience.dataforge.meta.*
|
|
||||||
import space.kscience.dataforge.names.*
|
|
||||||
import space.kscience.snark.SnarkBuilder
|
|
||||||
import space.kscience.snark.html.Language.Companion.SITE_LANGUAGES_KEY
|
|
||||||
import space.kscience.snark.html.Language.Companion.SITE_LANGUAGE_KEY
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public class Language : Scheme() {
|
|
||||||
/**
|
|
||||||
* Language key override
|
|
||||||
*/
|
|
||||||
public var key: String? by string()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Page name prefix
|
|
||||||
*/
|
|
||||||
public var prefix: String? by string()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Target page name with a given language key
|
|
||||||
*/
|
|
||||||
public var target: Name?
|
|
||||||
get() = meta["target"].string?.parseAsName(false)
|
|
||||||
set(value) {
|
|
||||||
meta["target"] = value?.toString()?.asValue()
|
|
||||||
}
|
|
||||||
|
|
||||||
public companion object : SchemeSpec<Language>(::Language) {
|
|
||||||
|
|
||||||
public val LANGUAGE_KEY: Name = "language".asName()
|
|
||||||
|
|
||||||
public val LANGUAGES_KEY: Name = "languages".asName()
|
|
||||||
|
|
||||||
public val SITE_LANGUAGE_KEY: Name = SiteBuilder.SITE_META_KEY + LANGUAGE_KEY
|
|
||||||
|
|
||||||
public val SITE_LANGUAGES_KEY: Name = SiteBuilder.SITE_META_KEY + LANGUAGES_KEY
|
|
||||||
|
|
||||||
public const val DEFAULT_LANGUAGE: String = "en"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Automatically build a language map for a data piece with given [name] based on existence of appropriate data nodes.
|
|
||||||
*/
|
|
||||||
context(SiteBuilder)
|
|
||||||
public fun forName(name: Name): Meta = Meta {
|
|
||||||
val currentLanguagePrefix = languages[language]?.get(Language::prefix.name)?.string ?: language
|
|
||||||
val fullName = (route.removeFirstOrNull(currentLanguagePrefix.asName()) ?: route) + name
|
|
||||||
languages.forEach { (key, meta) ->
|
|
||||||
val languagePrefix: String = meta[Language::prefix.name].string ?: key
|
|
||||||
val nameWithLanguage: Name = if (languagePrefix.isBlank()) {
|
|
||||||
fullName
|
|
||||||
} else {
|
|
||||||
languagePrefix.asName() + fullName
|
|
||||||
}
|
|
||||||
if (data.getItem(name) != null) {
|
|
||||||
key put meta.asMutableMeta().apply {
|
|
||||||
Language::target.name put nameWithLanguage.toString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public val SiteBuilder.languages: Map<String, Meta>
|
|
||||||
get() = siteMeta[SITE_LANGUAGES_KEY]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap()
|
|
||||||
|
|
||||||
public val SiteBuilder.language: String
|
|
||||||
get() = siteMeta[SITE_LANGUAGE_KEY].string ?: Language.DEFAULT_LANGUAGE
|
|
||||||
|
|
||||||
public val SiteBuilder.languagePrefix: Name
|
|
||||||
get() = languages[language]?.let { it[Language::prefix.name].string ?: language }?.parseAsName() ?: Name.EMPTY
|
|
||||||
|
|
||||||
public fun SiteBuilder.withLanguages(languageMap: Map<String, Meta>, block: SiteBuilder.(language: String) -> Unit) {
|
|
||||||
languageMap.forEach { (languageKey, languageMeta) ->
|
|
||||||
val prefix = languageMeta[Language::prefix.name].string ?: languageKey
|
|
||||||
val routeMeta = Meta {
|
|
||||||
SITE_LANGUAGE_KEY put languageKey
|
|
||||||
SITE_LANGUAGES_KEY put Meta {
|
|
||||||
languageMap.forEach {
|
|
||||||
it.key put it.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
route(prefix, routeMeta = routeMeta) {
|
|
||||||
block(languageKey)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SnarkBuilder
|
|
||||||
public fun SiteBuilder.withLanguages(
|
|
||||||
vararg language: Pair<String, String>,
|
|
||||||
block: SiteBuilder.(language: String) -> Unit,
|
|
||||||
) {
|
|
||||||
val languageMap = language.associate {
|
|
||||||
it.first to Meta {
|
|
||||||
Language::prefix.name put it.second
|
|
||||||
}
|
|
||||||
}
|
|
||||||
withLanguages(languageMap, block)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The language key of this page
|
|
||||||
*/
|
|
||||||
public val WebPage.language: String
|
|
||||||
get() = pageMeta[Language.LANGUAGE_KEY]?.string ?: pageMeta[SITE_LANGUAGE_KEY]?.string ?: Language.DEFAULT_LANGUAGE
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mapping of language keys to other language versions of this page
|
|
||||||
*/
|
|
||||||
public val WebPage.languages: Map<String, Meta>
|
|
||||||
get() = pageMeta[Language.LANGUAGES_KEY]?.items?.mapKeys { it.key.toStringUnescaped() } ?: emptyMap()
|
|
||||||
|
|
||||||
public fun WebPage.localisedPageRef(pageName: Name, relative: Boolean = false): String {
|
|
||||||
val prefix = languages[language]?.get(Language::prefix.name)?.string?.parseAsName() ?: Name.EMPTY
|
|
||||||
return resolvePageRef(prefix + pageName, relative)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render all pages in a node with given name. Use localization prefix if appropriate data is available.
|
|
||||||
*/
|
|
||||||
public fun SiteBuilder.localizedPages(
|
|
||||||
dataPath: Name,
|
|
||||||
remotePath: Name = dataPath,
|
|
||||||
dataRenderer: DataRenderer = DataRenderer.DEFAULT,
|
|
||||||
) {
|
|
||||||
val item = data.getItem(languagePrefix + dataPath)
|
|
||||||
?: data.getItem(dataPath)
|
|
||||||
?: error("No data found by name $dataPath")
|
|
||||||
route(remotePath) {
|
|
||||||
pages(item, dataRenderer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun SiteBuilder.localizedPages(
|
|
||||||
dataPath: String,
|
|
||||||
remotePath: Name = dataPath.parseAsName(),
|
|
||||||
dataRenderer: DataRenderer = DataRenderer.DEFAULT,
|
|
||||||
) {
|
|
||||||
localizedPages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
|
|
||||||
}
|
|
@ -1,248 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import kotlinx.html.HTML
|
|
||||||
import space.kscience.dataforge.context.Context
|
|
||||||
import space.kscience.dataforge.context.ContextAware
|
|
||||||
import space.kscience.dataforge.data.DataTree
|
|
||||||
import space.kscience.dataforge.data.DataTreeItem
|
|
||||||
import space.kscience.dataforge.data.branch
|
|
||||||
import space.kscience.dataforge.data.getItem
|
|
||||||
import space.kscience.dataforge.meta.Meta
|
|
||||||
import space.kscience.dataforge.meta.get
|
|
||||||
import space.kscience.dataforge.meta.getIndexed
|
|
||||||
import space.kscience.dataforge.meta.string
|
|
||||||
import space.kscience.dataforge.names.Name
|
|
||||||
import space.kscience.dataforge.names.NameToken
|
|
||||||
import space.kscience.dataforge.names.asName
|
|
||||||
import space.kscience.dataforge.names.parseAsName
|
|
||||||
import space.kscience.snark.SnarkBuilder
|
|
||||||
import space.kscience.snark.SnarkContext
|
|
||||||
import space.kscience.snark.html.SiteLayout.Companion.LAYOUT_KEY
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An abstraction, which is used to render sites to the different rendering engines
|
|
||||||
*/
|
|
||||||
@SnarkBuilder
|
|
||||||
public interface SiteBuilder : ContextAware, SnarkContext {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Route name of this [SiteBuilder] relative to the site root
|
|
||||||
*/
|
|
||||||
public val route: Name
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Data used for site construction. The type of the data is not limited
|
|
||||||
*/
|
|
||||||
public val data: DataTree<*>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Snark plugin and context used for layout resolution, preprocessors, etc
|
|
||||||
*/
|
|
||||||
public val snark: SnarkHtmlPlugin
|
|
||||||
|
|
||||||
override val context: Context get() = snark.context
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Site configuration
|
|
||||||
*/
|
|
||||||
public val siteMeta: Meta
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Serve a static data as a file from [data] with given [dataName] at given [routeName].
|
|
||||||
*/
|
|
||||||
public fun static(dataName: Name, routeName: Name = dataName)
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a single page at given [route]. If route is empty, create an index page at current route.
|
|
||||||
*
|
|
||||||
* @param pageMeta additional page meta. [WebPage] will use both it and [siteMeta]
|
|
||||||
*/
|
|
||||||
@SnarkBuilder
|
|
||||||
public fun page(route: Name = Name.EMPTY, pageMeta: Meta = Meta.EMPTY, content: context(HTML, WebPage) () -> Unit)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a route with optional data tree override. For example one could use a subtree of the initial tree.
|
|
||||||
* By default, the same data tree is used for route.
|
|
||||||
*/
|
|
||||||
public fun route(
|
|
||||||
routeName: Name,
|
|
||||||
dataOverride: DataTree<*>? = null,
|
|
||||||
routeMeta: Meta = Meta.EMPTY,
|
|
||||||
): SiteBuilder
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a route and sets it as site base url
|
|
||||||
*/
|
|
||||||
public fun site(
|
|
||||||
routeName: Name,
|
|
||||||
dataOverride: DataTree<*>? = null,
|
|
||||||
routeMeta: Meta = Meta.EMPTY,
|
|
||||||
): SiteBuilder
|
|
||||||
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
public val SITE_META_KEY: Name = "site".asName()
|
|
||||||
public val INDEX_PAGE_TOKEN: NameToken = NameToken("index")
|
|
||||||
public val UP_PAGE_TOKEN: NameToken = NameToken("..")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context(SiteBuilder)
|
|
||||||
public val site: SiteBuilder
|
|
||||||
get() = this@SiteBuilder
|
|
||||||
|
|
||||||
@SnarkBuilder
|
|
||||||
public inline fun SiteBuilder.route(
|
|
||||||
route: Name,
|
|
||||||
dataOverride: DataTree<*>? = null,
|
|
||||||
routeMeta: Meta = Meta.EMPTY,
|
|
||||||
block: SiteBuilder.() -> Unit,
|
|
||||||
) {
|
|
||||||
route(route, dataOverride, routeMeta).apply(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SnarkBuilder
|
|
||||||
public inline fun SiteBuilder.route(
|
|
||||||
route: String,
|
|
||||||
dataOverride: DataTree<*>? = null,
|
|
||||||
routeMeta: Meta = Meta.EMPTY,
|
|
||||||
block: SiteBuilder.() -> Unit,
|
|
||||||
) {
|
|
||||||
route(route.parseAsName(), dataOverride, routeMeta).apply(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SnarkBuilder
|
|
||||||
public inline fun SiteBuilder.site(
|
|
||||||
route: Name,
|
|
||||||
dataOverride: DataTree<*>? = null,
|
|
||||||
routeMeta: Meta = Meta.EMPTY,
|
|
||||||
block: SiteBuilder.() -> Unit,
|
|
||||||
) {
|
|
||||||
site(route, dataOverride, routeMeta).apply(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SnarkBuilder
|
|
||||||
public inline fun SiteBuilder.site(
|
|
||||||
route: String,
|
|
||||||
dataOverride: DataTree<*>? = null,
|
|
||||||
routeMeta: Meta = Meta.EMPTY,
|
|
||||||
block: SiteBuilder.() -> Unit,
|
|
||||||
) {
|
|
||||||
site(route.parseAsName(), dataOverride, routeMeta).apply(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
public inline fun SiteBuilder.withData(
|
|
||||||
data: DataTree<*>,
|
|
||||||
block: SiteBuilder.() -> Unit
|
|
||||||
){
|
|
||||||
route(Name.EMPTY, data).apply(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
public inline fun SiteBuilder.withDataBranch(
|
|
||||||
name: Name,
|
|
||||||
block: SiteBuilder.() -> Unit
|
|
||||||
){
|
|
||||||
route(Name.EMPTY, data.branch(name)).apply(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
public inline fun SiteBuilder.withDataBranch(
|
|
||||||
name: String,
|
|
||||||
block: SiteBuilder.() -> Unit
|
|
||||||
){
|
|
||||||
route(Name.EMPTY, data.branch(name)).apply(block)
|
|
||||||
}
|
|
||||||
|
|
||||||
///**
|
|
||||||
// * Create a stand-alone site at a given node
|
|
||||||
// */
|
|
||||||
//public fun SiteBuilder.site(route: Name, dataRoot: DataTree<*>, block: SiteBuilder.() -> Unit) {
|
|
||||||
// val mountedData = data.copy(
|
|
||||||
// data = dataRoot,
|
|
||||||
// baseUrlPath = data.resolveRef(route.tokens.joinToString(separator = "/")),
|
|
||||||
// meta = Laminate(dataRoot.meta, data.meta) //layering dataRoot meta over existing data
|
|
||||||
// )
|
|
||||||
// route(route) {
|
|
||||||
// withData(mountedData).block()
|
|
||||||
// }
|
|
||||||
//}
|
|
||||||
|
|
||||||
public fun SiteBuilder.static(dataName: String): Unit = static(dataName.parseAsName())
|
|
||||||
|
|
||||||
public fun SiteBuilder.static(dataName: String, routeName: String): Unit = static(
|
|
||||||
dataName.parseAsName(),
|
|
||||||
routeName.parseAsName()
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun SiteBuilder.assetsFrom(rootMeta: Meta) {
|
|
||||||
rootMeta.getIndexed("file".asName()).forEach { (_, meta) ->
|
|
||||||
val webName: String? by meta.string()
|
|
||||||
val name by meta.string { error("File path is not provided") }
|
|
||||||
val fileName = name.parseAsName()
|
|
||||||
static(fileName, webName?.parseAsName() ?: fileName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively renders the data items in [data]. If [LAYOUT_KEY] is defined in an item, use it to load
|
|
||||||
* layout from the context, otherwise render children nodes as name segments and individual data items using [dataRenderer].
|
|
||||||
*/
|
|
||||||
public fun SiteBuilder.pages(
|
|
||||||
data: DataTreeItem<*>,
|
|
||||||
dataRenderer: DataRenderer = DataRenderer.DEFAULT,
|
|
||||||
) {
|
|
||||||
val layoutMeta = data.meta[LAYOUT_KEY]
|
|
||||||
if (layoutMeta != null) {
|
|
||||||
//use layout if it is defined
|
|
||||||
snark.siteLayout(layoutMeta).render(data)
|
|
||||||
} else {
|
|
||||||
when (data) {
|
|
||||||
is DataTreeItem.Node -> {
|
|
||||||
data.tree.items.forEach { (token, item) ->
|
|
||||||
//Don't apply index token
|
|
||||||
if (token == SiteLayout.INDEX_PAGE_TOKEN) {
|
|
||||||
pages(item, dataRenderer)
|
|
||||||
} else if (item is DataTreeItem.Leaf) {
|
|
||||||
dataRenderer(token.asName(), item.data)
|
|
||||||
} else {
|
|
||||||
route(token.asName()) {
|
|
||||||
pages(item, dataRenderer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is DataTreeItem.Leaf -> {
|
|
||||||
dataRenderer(Name.EMPTY, data.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
data.meta[SiteLayout.ASSETS_KEY]?.let {
|
|
||||||
assetsFrom(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
//TODO watch for changes
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Render all pages in a node with given name
|
|
||||||
*/
|
|
||||||
public fun SiteBuilder.pages(
|
|
||||||
dataPath: Name,
|
|
||||||
remotePath: Name = dataPath,
|
|
||||||
dataRenderer: DataRenderer = DataRenderer.DEFAULT,
|
|
||||||
) {
|
|
||||||
val item = data.getItem(dataPath) ?: error("No data found by name $dataPath")
|
|
||||||
route(remotePath) {
|
|
||||||
pages(item, dataRenderer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun SiteBuilder.pages(
|
|
||||||
dataPath: String,
|
|
||||||
remotePath: Name = dataPath.parseAsName(),
|
|
||||||
dataRenderer: DataRenderer = DataRenderer.DEFAULT,
|
|
||||||
) {
|
|
||||||
pages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
|
|
||||||
}
|
|
@ -1,32 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import space.kscience.dataforge.data.DataTreeItem
|
|
||||||
import space.kscience.dataforge.misc.Type
|
|
||||||
import space.kscience.dataforge.names.NameToken
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An abstraction to render singular data or a data tree.
|
|
||||||
*/
|
|
||||||
@Type(SiteLayout.TYPE)
|
|
||||||
public fun interface SiteLayout {
|
|
||||||
|
|
||||||
context(SiteBuilder)
|
|
||||||
public fun render(item: DataTreeItem<*>)
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
public const val TYPE: String = "snark.layout"
|
|
||||||
public const val LAYOUT_KEY: String = "layout"
|
|
||||||
public const val ASSETS_KEY: String = "assets"
|
|
||||||
public val INDEX_PAGE_TOKEN: NameToken = NameToken("index")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default [SiteLayout]. It renders all [HtmlData] pages with simple headers via [SiteLayout.defaultDataRenderer]
|
|
||||||
*/
|
|
||||||
public object DefaultSiteLayout : SiteLayout {
|
|
||||||
context(SiteBuilder) override fun render(item: DataTreeItem<*>) {
|
|
||||||
pages(item)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,124 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import io.ktor.utils.io.core.readBytes
|
|
||||||
import space.kscience.dataforge.context.*
|
|
||||||
import space.kscience.dataforge.data.DataTree
|
|
||||||
import space.kscience.dataforge.data.node
|
|
||||||
import space.kscience.dataforge.io.IOPlugin
|
|
||||||
import space.kscience.dataforge.io.IOReader
|
|
||||||
import space.kscience.dataforge.io.JsonMetaFormat
|
|
||||||
import space.kscience.dataforge.io.yaml.YamlMetaFormat
|
|
||||||
import space.kscience.dataforge.io.yaml.YamlPlugin
|
|
||||||
import space.kscience.dataforge.meta.Meta
|
|
||||||
import space.kscience.dataforge.meta.get
|
|
||||||
import space.kscience.dataforge.meta.string
|
|
||||||
import space.kscience.dataforge.misc.DFExperimental
|
|
||||||
import space.kscience.dataforge.names.Name
|
|
||||||
import space.kscience.dataforge.names.asName
|
|
||||||
import space.kscience.dataforge.names.parseAsName
|
|
||||||
import space.kscience.dataforge.workspace.FileData
|
|
||||||
import space.kscience.dataforge.workspace.readDataDirectory
|
|
||||||
import space.kscience.snark.SnarkParser
|
|
||||||
import java.nio.file.Path
|
|
||||||
import kotlin.io.path.extension
|
|
||||||
import kotlin.io.path.toPath
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A plugin used for rendering a [DataTree] as HTML
|
|
||||||
*/
|
|
||||||
public class SnarkHtmlPlugin : AbstractPlugin() {
|
|
||||||
private val yaml by require(YamlPlugin)
|
|
||||||
public val io: IOPlugin get() = yaml.io
|
|
||||||
|
|
||||||
override val tag: PluginTag get() = Companion.tag
|
|
||||||
|
|
||||||
internal val parsers: Map<Name, SnarkParser<Any>> by lazy {
|
|
||||||
context.gather(SnarkParser.TYPE, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val siteLayouts: Map<Name, SiteLayout> by lazy {
|
|
||||||
context.gather(SiteLayout.TYPE, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val textProcessors: Map<Name, TextProcessor> by lazy {
|
|
||||||
context.gather(TextProcessor.TYPE, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun siteLayout(layoutMeta: Meta): SiteLayout {
|
|
||||||
val layoutName = layoutMeta.string
|
|
||||||
?: layoutMeta["name"].string ?: error("Layout name not defined in $layoutMeta")
|
|
||||||
return siteLayouts[layoutName.parseAsName()] ?: error("Layout with name $layoutName not found in $this")
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun textProcessor(transformationMeta: Meta): TextProcessor {
|
|
||||||
val transformationName = transformationMeta.string
|
|
||||||
?: transformationMeta["name"].string ?: error("Transformation name not defined in $transformationMeta")
|
|
||||||
return textProcessors[transformationName.parseAsName()]
|
|
||||||
?: error("Text transformation with name $transformationName not found in $this")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun content(target: String): Map<Name, Any> = when (target) {
|
|
||||||
SnarkParser.TYPE -> mapOf(
|
|
||||||
"html".asName() to SnarkHtmlParser,
|
|
||||||
"markdown".asName() to SnarkMarkdownParser,
|
|
||||||
"json".asName() to SnarkParser(JsonMetaFormat, "json"),
|
|
||||||
"yaml".asName() to SnarkParser(YamlMetaFormat, "yaml", "yml"),
|
|
||||||
"png".asName() to SnarkParser(ImageIOReader, "png"),
|
|
||||||
"jpg".asName() to SnarkParser(ImageIOReader, "jpg", "jpeg"),
|
|
||||||
"gif".asName() to SnarkParser(ImageIOReader, "gif"),
|
|
||||||
"svg".asName() to SnarkParser(IOReader.binary, "svg"),
|
|
||||||
"raw".asName() to SnarkParser(IOReader.binary, "css", "js", "scss", "woff", "woff2", "ttf", "eot")
|
|
||||||
)
|
|
||||||
|
|
||||||
TextProcessor.TYPE -> mapOf(
|
|
||||||
"basic".asName() to BasicTextProcessor
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> super.content(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
public companion object : PluginFactory<SnarkHtmlPlugin> {
|
|
||||||
override val tag: PluginTag = PluginTag("snark")
|
|
||||||
|
|
||||||
override fun build(context: Context, meta: Meta): SnarkHtmlPlugin = SnarkHtmlPlugin()
|
|
||||||
|
|
||||||
private val byteArrayIOReader = IOReader {
|
|
||||||
readBytes()
|
|
||||||
}
|
|
||||||
|
|
||||||
internal val byteArraySnarkParser = SnarkParser(byteArrayIOReader)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(DFExperimental::class)
|
|
||||||
public fun SnarkHtmlPlugin.readDirectory(path: Path): DataTree<Any> = io.readDataDirectory(
|
|
||||||
path,
|
|
||||||
setOf("md", "html", "yaml", "json")
|
|
||||||
) { dataPath, meta ->
|
|
||||||
val fileExtension = meta[FileData.FILE_EXTENSION_KEY].string ?: dataPath.extension
|
|
||||||
val parser: SnarkParser<Any> = parsers.values.filter { parser ->
|
|
||||||
fileExtension in parser.fileExtensions
|
|
||||||
}.maxByOrNull {
|
|
||||||
it.priority
|
|
||||||
} ?: run {
|
|
||||||
logger.debug { "The parser is not found for file $dataPath with meta $meta" }
|
|
||||||
SnarkHtmlPlugin.byteArraySnarkParser
|
|
||||||
}
|
|
||||||
|
|
||||||
parser.asReader(context, meta)
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun SnarkHtmlPlugin.readResources(
|
|
||||||
vararg resources: String,
|
|
||||||
classLoader: ClassLoader = Thread.currentThread().contextClassLoader,
|
|
||||||
): DataTree<Any> {
|
|
||||||
// require(resource.isNotBlank()) {"Can't mount root resource tree as data root"}
|
|
||||||
return DataTree {
|
|
||||||
resources.forEach { resource ->
|
|
||||||
val path = classLoader.getResource(resource)?.toURI()?.toPath() ?: error(
|
|
||||||
"Resource with name $resource is not resolved"
|
|
||||||
)
|
|
||||||
node(resource, readDirectory(path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,58 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import kotlinx.html.div
|
|
||||||
import kotlinx.html.unsafe
|
|
||||||
import org.intellij.markdown.flavours.commonmark.CommonMarkFlavourDescriptor
|
|
||||||
import org.intellij.markdown.html.HtmlGenerator
|
|
||||||
import org.intellij.markdown.parser.MarkdownParser
|
|
||||||
import space.kscience.dataforge.context.Context
|
|
||||||
import space.kscience.dataforge.meta.Meta
|
|
||||||
import space.kscience.dataforge.meta.get
|
|
||||||
import space.kscience.snark.SnarkParser
|
|
||||||
import kotlin.reflect.KType
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
public abstract class SnarkTextParser<R> : SnarkParser<R> {
|
|
||||||
public abstract fun parseText(text: String, meta: Meta): R
|
|
||||||
|
|
||||||
override fun parse(context: Context, meta: Meta, bytes: ByteArray): R =
|
|
||||||
parseText(bytes.decodeToString(), meta)
|
|
||||||
|
|
||||||
public fun transformText(text: String, meta: Meta, page: WebPage): String =
|
|
||||||
meta[TextProcessor.TEXT_TRANSFORMATION_KEY]?.let {
|
|
||||||
with(page) { page.snark.textProcessor(it).process(text) }
|
|
||||||
} ?: text
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
internal object SnarkHtmlParser : SnarkTextParser<HtmlFragment>() {
|
|
||||||
override val fileExtensions: Set<String> = setOf("html")
|
|
||||||
override val type: KType = typeOf<HtmlFragment>()
|
|
||||||
|
|
||||||
override fun parseText(text: String, meta: Meta): HtmlFragment = HtmlFragment { page ->
|
|
||||||
div {
|
|
||||||
unsafe { +transformText(text, meta, page) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object SnarkMarkdownParser : SnarkTextParser<HtmlFragment>() {
|
|
||||||
override val fileExtensions: Set<String> = setOf("markdown", "mdown", "mkdn", "mkd", "md")
|
|
||||||
override val type: KType = typeOf<HtmlFragment>()
|
|
||||||
|
|
||||||
private val markdownFlavor = CommonMarkFlavourDescriptor()
|
|
||||||
private val markdownParser = MarkdownParser(markdownFlavor)
|
|
||||||
|
|
||||||
override fun parseText(text: String, meta: Meta): HtmlFragment = HtmlFragment { page ->
|
|
||||||
val transformedText = SnarkHtmlParser.transformText(text, meta, page)
|
|
||||||
val parsedTree = markdownParser.buildMarkdownTreeFromString(transformedText)
|
|
||||||
val htmlString = HtmlGenerator(transformedText, parsedTree, markdownFlavor).generateHtml()
|
|
||||||
|
|
||||||
div {
|
|
||||||
unsafe {
|
|
||||||
+htmlString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import io.ktor.utils.io.streams.asInput
|
|
||||||
import io.ktor.utils.io.streams.asOutput
|
|
||||||
import kotlinx.coroutines.runBlocking
|
|
||||||
import kotlinx.html.HTML
|
|
||||||
import kotlinx.html.html
|
|
||||||
import kotlinx.html.stream.createHTML
|
|
||||||
import space.kscience.dataforge.data.DataTree
|
|
||||||
import space.kscience.dataforge.data.DataTreeItem
|
|
||||||
import space.kscience.dataforge.data.await
|
|
||||||
import space.kscience.dataforge.data.getItem
|
|
||||||
import space.kscience.dataforge.io.Binary
|
|
||||||
import space.kscience.dataforge.io.toByteArray
|
|
||||||
import space.kscience.dataforge.io.writeBinary
|
|
||||||
import space.kscience.dataforge.meta.*
|
|
||||||
import space.kscience.dataforge.names.Name
|
|
||||||
import space.kscience.dataforge.names.isEmpty
|
|
||||||
import space.kscience.dataforge.names.plus
|
|
||||||
import space.kscience.dataforge.workspace.FileData
|
|
||||||
import java.nio.file.Path
|
|
||||||
import kotlin.contracts.InvocationKind
|
|
||||||
import kotlin.contracts.contract
|
|
||||||
import kotlin.io.path.*
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An implementation of [SiteBuilder] to render site as a static directory [outputPath]
|
|
||||||
*/
|
|
||||||
internal class StaticSiteBuilder(
|
|
||||||
override val snark: SnarkHtmlPlugin,
|
|
||||||
override val data: DataTree<*>,
|
|
||||||
override val siteMeta: Meta,
|
|
||||||
private val baseUrl: String,
|
|
||||||
override val route: Name,
|
|
||||||
private val outputPath: Path,
|
|
||||||
) : SiteBuilder {
|
|
||||||
|
|
||||||
|
|
||||||
// private fun Path.copyRecursively(target: Path) {
|
|
||||||
// Files.walk(this).forEach { source: Path ->
|
|
||||||
// val destination: Path = target.resolve(source.relativeTo(this))
|
|
||||||
// if (!destination.isDirectory()) {
|
|
||||||
// //avoid re-creating directories
|
|
||||||
// source.copyTo(destination, true)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
@OptIn(ExperimentalPathApi::class)
|
|
||||||
private suspend fun files(item: DataTreeItem<Any>, routeName: Name) {
|
|
||||||
//try using direct file rendering
|
|
||||||
item.meta[FileData.FILE_PATH_KEY]?.string?.let {
|
|
||||||
val file = Path.of(it)
|
|
||||||
val targetPath = outputPath.resolve(routeName.toWebPath())
|
|
||||||
targetPath.parent.createDirectories()
|
|
||||||
file.copyToRecursively(targetPath, followLinks = false)
|
|
||||||
//success, don't do anything else
|
|
||||||
return@files
|
|
||||||
}
|
|
||||||
|
|
||||||
when (item) {
|
|
||||||
is DataTreeItem.Leaf -> {
|
|
||||||
val datum = item.data
|
|
||||||
if (datum.type != typeOf<Binary>()) error("Can't directly serve file of type ${item.data.type}")
|
|
||||||
val targetPath = outputPath.resolve(routeName.toWebPath())
|
|
||||||
val binary = datum.await() as Binary
|
|
||||||
targetPath.outputStream().asOutput().use{
|
|
||||||
it.writeBinary(binary)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is DataTreeItem.Node -> {
|
|
||||||
item.tree.items.forEach { (token, childItem) ->
|
|
||||||
files(childItem, routeName + token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun static(dataName: Name, routeName: Name) {
|
|
||||||
val item: DataTreeItem<Any> = data.getItem(dataName) ?: error("Data with name $dataName is not resolved")
|
|
||||||
runBlocking {
|
|
||||||
files(item, routeName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// override fun file(file: Path, webPath: String) {
|
|
||||||
// val targetPath = outputPath.resolve(webPath)
|
|
||||||
// if (file.isDirectory()) {
|
|
||||||
// targetPath.parent.createDirectories()
|
|
||||||
// file.copyRecursively(targetPath)
|
|
||||||
// } else if (webPath.isBlank()) {
|
|
||||||
// error("Can't mount file to an empty route")
|
|
||||||
// } else {
|
|
||||||
// targetPath.parent.createDirectories()
|
|
||||||
// file.copyTo(targetPath, true)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun resourceFile(resourcesPath: String, webPath: String) {
|
|
||||||
// val targetPath = outputPath.resolve(webPath)
|
|
||||||
// targetPath.parent.createDirectories()
|
|
||||||
// javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyTo(targetPath, true)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// override fun resourceDirectory(resourcesPath: String) {
|
|
||||||
// outputPath.parent.createDirectories()
|
|
||||||
// javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyRecursively(outputPath)
|
|
||||||
// }
|
|
||||||
|
|
||||||
private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) {
|
|
||||||
ref
|
|
||||||
} else if (ref.isEmpty()) {
|
|
||||||
baseUrl
|
|
||||||
} else {
|
|
||||||
"${baseUrl.removeSuffix("/")}/$ref"
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class StaticWebPage(override val pageMeta: Meta) : WebPage {
|
|
||||||
override val data: DataTree<*> get() = this@StaticSiteBuilder.data
|
|
||||||
|
|
||||||
override val snark: SnarkHtmlPlugin get() = this@StaticSiteBuilder.snark
|
|
||||||
|
|
||||||
|
|
||||||
override fun resolveRef(ref: String): String =
|
|
||||||
this@StaticSiteBuilder.resolveRef(this@StaticSiteBuilder.baseUrl, ref)
|
|
||||||
|
|
||||||
override fun resolvePageRef(pageName: Name, relative: Boolean): String = resolveRef(
|
|
||||||
(if (relative) this@StaticSiteBuilder.route + pageName else pageName).toWebPath() + ".html"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun page(route: Name, pageMeta: Meta, content: context(HTML) WebPage.() -> Unit) {
|
|
||||||
val htmlBuilder = createHTML()
|
|
||||||
|
|
||||||
val modifiedPageMeta = pageMeta.toMutableMeta().apply {
|
|
||||||
"name" put route.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
htmlBuilder.html {
|
|
||||||
content(this, StaticWebPage(Laminate(modifiedPageMeta, siteMeta)))
|
|
||||||
}
|
|
||||||
|
|
||||||
val newPath = if (route.isEmpty()) {
|
|
||||||
outputPath.resolve("index.html")
|
|
||||||
} else {
|
|
||||||
outputPath.resolve(route.toWebPath() + ".html")
|
|
||||||
}
|
|
||||||
|
|
||||||
newPath.parent.createDirectories()
|
|
||||||
newPath.writeText(htmlBuilder.finalize())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun route(
|
|
||||||
routeName: Name,
|
|
||||||
dataOverride: DataTree<*>?,
|
|
||||||
routeMeta: Meta,
|
|
||||||
): SiteBuilder = StaticSiteBuilder(
|
|
||||||
snark = snark,
|
|
||||||
data = dataOverride ?: data,
|
|
||||||
siteMeta = Laminate(routeMeta, siteMeta),
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
route = route + routeName,
|
|
||||||
outputPath = outputPath.resolve(routeName.toWebPath())
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun site(
|
|
||||||
routeName: Name,
|
|
||||||
dataOverride: DataTree<*>?,
|
|
||||||
routeMeta: Meta,
|
|
||||||
): SiteBuilder = StaticSiteBuilder(
|
|
||||||
snark = snark,
|
|
||||||
data = dataOverride ?: data,
|
|
||||||
siteMeta = Laminate(routeMeta, siteMeta),
|
|
||||||
baseUrl = if (baseUrl == "") "" else resolveRef(baseUrl, routeName.toWebPath()),
|
|
||||||
route = Name.EMPTY,
|
|
||||||
outputPath = outputPath.resolve(routeName.toWebPath())
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a static site using given [SnarkEnvironment] in provided [outputPath].
|
|
||||||
* Use [siteUrl] as a base for all resolved URLs. By default, use [outputPath] absolute path as a base.
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public fun SnarkHtmlPlugin.static(
|
|
||||||
data: DataTree<*>,
|
|
||||||
outputPath: Path,
|
|
||||||
siteUrl: String = outputPath.absolutePathString().replace("\\", "/"),
|
|
||||||
siteMeta: Meta = data.meta,
|
|
||||||
block: SiteBuilder.() -> Unit,
|
|
||||||
) {
|
|
||||||
contract {
|
|
||||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
|
||||||
}
|
|
||||||
StaticSiteBuilder(this, data, siteMeta, siteUrl, Name.EMPTY, outputPath).block()
|
|
||||||
}
|
|
@ -1,61 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import space.kscience.dataforge.meta.get
|
|
||||||
import space.kscience.dataforge.meta.string
|
|
||||||
import space.kscience.dataforge.misc.Type
|
|
||||||
import space.kscience.dataforge.names.NameToken
|
|
||||||
import space.kscience.dataforge.names.parseAsName
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An object that conducts page-based text transformation. Like using link replacement or templating.
|
|
||||||
*/
|
|
||||||
@Type(TextProcessor.TYPE)
|
|
||||||
public fun interface TextProcessor {
|
|
||||||
context(WebPage)
|
|
||||||
public fun process(text: String): String
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
public const val TYPE: String = "snark.textTransformation"
|
|
||||||
public val TEXT_TRANSFORMATION_KEY: NameToken = NameToken("transformation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A basic [TextProcessor] that replaces `${...}` expressions in text. The following expressions are recognised:
|
|
||||||
* * `homeRef` resolves to [homeRef]
|
|
||||||
* * `resolveRef("...")` -> [WebPage.resolveRef]
|
|
||||||
* * `resolvePageRef("...")` -> [WebPage.resolvePageRef]
|
|
||||||
* * `pageMeta.get("...") -> [WebPage.pageMeta] get string method
|
|
||||||
* Otherwise return unchanged string
|
|
||||||
*/
|
|
||||||
public object BasicTextProcessor : TextProcessor {
|
|
||||||
|
|
||||||
private val regex = """\$\{([\w.]*)(?>\("(.*)"\))?}""".toRegex()
|
|
||||||
|
|
||||||
context(WebPage)
|
|
||||||
override fun process(text: String): String = text.replace(regex) { match ->
|
|
||||||
when (match.groups[1]!!.value) {
|
|
||||||
"homeRef" -> homeRef
|
|
||||||
"resolveRef" -> {
|
|
||||||
val refString = match.groups[2]?.value ?: error("resolveRef requires a string (quoted) argument")
|
|
||||||
resolveRef(refString)
|
|
||||||
}
|
|
||||||
|
|
||||||
"resolvePageRef" -> {
|
|
||||||
val refString = match.groups[2]?.value
|
|
||||||
?: error("resolvePageRef requires a string (quoted) argument")
|
|
||||||
localisedPageRef(refString.parseAsName())
|
|
||||||
}
|
|
||||||
|
|
||||||
"pageMeta.get" -> {
|
|
||||||
val nameString = match.groups[2]?.value
|
|
||||||
?: error("resolvePageRef requires a string (quoted) argument")
|
|
||||||
pageMeta[nameString.parseAsName()].string ?: "@null"
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> match.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import space.kscience.dataforge.context.Context
|
|
||||||
import space.kscience.dataforge.context.ContextAware
|
|
||||||
import space.kscience.dataforge.data.*
|
|
||||||
import space.kscience.dataforge.meta.Meta
|
|
||||||
import space.kscience.dataforge.meta.get
|
|
||||||
import space.kscience.dataforge.meta.string
|
|
||||||
import space.kscience.dataforge.names.*
|
|
||||||
import space.kscience.snark.SnarkBuilder
|
|
||||||
import space.kscience.snark.SnarkContext
|
|
||||||
|
|
||||||
context(SnarkContext)
|
|
||||||
public fun Name.toWebPath(): String = tokens.joinToString(separator = "/") {
|
|
||||||
if (it.hasIndex()) {
|
|
||||||
"${it.body}[${it.index}]"
|
|
||||||
} else {
|
|
||||||
it.body
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A context for building a single page
|
|
||||||
*/
|
|
||||||
@SnarkBuilder
|
|
||||||
public interface WebPage : ContextAware, SnarkContext {
|
|
||||||
|
|
||||||
public val snark: SnarkHtmlPlugin
|
|
||||||
|
|
||||||
override val context: Context get() = snark.context
|
|
||||||
|
|
||||||
public val data: DataTree<*>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A metadata for a page. It should include site metadata
|
|
||||||
*/
|
|
||||||
public val pageMeta: Meta
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve absolute url for given [ref]
|
|
||||||
*
|
|
||||||
*/
|
|
||||||
public fun resolveRef(ref: String): String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve absolute url for a page with given [pageName].
|
|
||||||
*
|
|
||||||
* @param relative if true, add [SiteBuilder] route to the absolute page name
|
|
||||||
*/
|
|
||||||
public fun resolvePageRef(pageName: Name, relative: Boolean = false): String
|
|
||||||
}
|
|
||||||
|
|
||||||
context(WebPage)
|
|
||||||
public val page: WebPage
|
|
||||||
get() = this@WebPage
|
|
||||||
|
|
||||||
public fun WebPage.resolvePageRef(pageName: String): String = resolvePageRef(pageName.parseAsName())
|
|
||||||
|
|
||||||
public val WebPage.homeRef: String get() = resolvePageRef(SiteBuilder.INDEX_PAGE_TOKEN.asName())
|
|
||||||
|
|
||||||
public val WebPage.name: Name? get() = pageMeta["name"].string?.parseAsName()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a Html builder by its full name
|
|
||||||
*/
|
|
||||||
context(SnarkContext)
|
|
||||||
public fun DataTree<*>.resolveHtmlOrNull(name: Name): HtmlData? {
|
|
||||||
val resolved = (getByType<HtmlFragment>(name) ?: getByType<HtmlFragment>(name + SiteBuilder.INDEX_PAGE_TOKEN))
|
|
||||||
|
|
||||||
return resolved?.takeIf {
|
|
||||||
it.published //TODO add language confirmation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
context(SnarkContext)
|
|
||||||
public fun DataTree<*>.resolveHtmlOrNull(name: String): HtmlData? = resolveHtmlOrNull(name.parseAsName())
|
|
||||||
|
|
||||||
context(SnarkContext)
|
|
||||||
public fun DataTree<*>.resolveHtml(name: String): HtmlData = resolveHtmlOrNull(name)
|
|
||||||
?: error("Html fragment with name $name is not resolved")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find all Html blocks using given name/meta filter
|
|
||||||
*/
|
|
||||||
context(SnarkContext)
|
|
||||||
public fun DataTree<*>.resolveAllHtml(predicate: (name: Name, meta: Meta) -> Boolean): Map<Name, HtmlData> =
|
|
||||||
filterByType<HtmlFragment> { name, meta ->
|
|
||||||
predicate(name, meta)
|
|
||||||
&& meta["published"].string != "false"
|
|
||||||
//TODO add language confirmation
|
|
||||||
}.asSequence().associate { it.name to it.data }
|
|
||||||
|
|
||||||
|
|
||||||
context(SnarkContext)
|
|
||||||
public fun DataTree<*>.findByContentType(
|
|
||||||
contentType: String,
|
|
||||||
baseName: Name = Name.EMPTY,
|
|
||||||
): Map<Name, Data<HtmlFragment>> = resolveAllHtml { name, meta ->
|
|
||||||
name.startsWith(baseName) && meta["content_type"].string == contentType
|
|
||||||
}
|
|
@ -1,37 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import space.kscience.dataforge.data.DataTreeItem
|
|
||||||
import space.kscience.dataforge.names.Name
|
|
||||||
import space.kscience.dataforge.names.parseAsName
|
|
||||||
|
|
||||||
public class SnarkHtmlEnvironmentBuilder {
|
|
||||||
public val layouts: HashMap<Name, SiteLayout> = HashMap()
|
|
||||||
|
|
||||||
public fun layout(name: String, body: context(SiteBuilder) (DataTreeItem<*>) -> Unit) {
|
|
||||||
layouts[name.parseAsName()] = object : SiteLayout {
|
|
||||||
context(SiteBuilder) override fun render(item: DataTreeItem<*>) = body(site, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
//public fun SnarkEnvironment.html(block: SnarkHtmlEnvironmentBuilder.() -> Unit) {
|
|
||||||
// contract {
|
|
||||||
// callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// val envBuilder = SnarkHtmlEnvironmentBuilder().apply(block)
|
|
||||||
//
|
|
||||||
// val plugin = object : AbstractPlugin() {
|
|
||||||
// val snark by require(SnarkHtmlPlugin)
|
|
||||||
//
|
|
||||||
// override val tag: PluginTag = PluginTag("@extension[${hashCode()}]")
|
|
||||||
//
|
|
||||||
//
|
|
||||||
// override fun content(target: String): Map<Name, Any> = when (target) {
|
|
||||||
// SiteLayout.TYPE -> envBuilder.layouts
|
|
||||||
// else -> super.content(target)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// registerPlugin(plugin)
|
|
||||||
//}
|
|
@ -1,5 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
id("space.kscience.gradle.jvm")
|
id("space.kscience.gradle.mpp")
|
||||||
`maven-publish`
|
`maven-publish`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -7,15 +7,17 @@ val dataforgeVersion: String by rootProject.extra
|
|||||||
val ktorVersion = space.kscience.gradle.KScienceVersions.ktorVersion
|
val ktorVersion = space.kscience.gradle.KScienceVersions.ktorVersion
|
||||||
|
|
||||||
kscience{
|
kscience{
|
||||||
|
jvm()
|
||||||
useContextReceivers()
|
useContextReceivers()
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
jvmMain{
|
||||||
api(projects.snarkHtml)
|
api(projects.snarkHtml)
|
||||||
|
|
||||||
api("io.ktor:ktor-server-core:$ktorVersion")
|
api("io.ktor:ktor-server-core:$ktorVersion")
|
||||||
api("io.ktor:ktor-server-html-builder:$ktorVersion")
|
api("io.ktor:ktor-server-html-builder:$ktorVersion")
|
||||||
api("io.ktor:ktor-server-host-common:$ktorVersion")
|
api("io.ktor:ktor-server-host-common:$ktorVersion")
|
||||||
|
}
|
||||||
testApi("io.ktor:ktor-server-tests:$ktorVersion")
|
jvmTest{
|
||||||
|
api("io.ktor:ktor-server-tests:$ktorVersion")
|
||||||
|
}
|
||||||
}
|
}
|
@ -0,0 +1,200 @@
|
|||||||
|
package space.kscience.snark.ktor
|
||||||
|
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.http.content.TextContent
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.http.content.staticFiles
|
||||||
|
import io.ktor.server.plugins.origin
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import io.ktor.server.response.respondBytes
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.createRouteFromPath
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.context.ContextAware
|
||||||
|
import space.kscience.dataforge.context.error
|
||||||
|
import space.kscience.dataforge.context.logger
|
||||||
|
import space.kscience.dataforge.data.Data
|
||||||
|
import space.kscience.dataforge.data.DataTree
|
||||||
|
import space.kscience.dataforge.data.await
|
||||||
|
import space.kscience.dataforge.data.meta
|
||||||
|
import space.kscience.dataforge.io.Binary
|
||||||
|
import space.kscience.dataforge.io.toByteArray
|
||||||
|
import space.kscience.dataforge.meta.*
|
||||||
|
import space.kscience.dataforge.names.Name
|
||||||
|
import space.kscience.dataforge.names.cutLast
|
||||||
|
import space.kscience.dataforge.names.endsWith
|
||||||
|
import space.kscience.dataforge.names.plus
|
||||||
|
import space.kscience.dataforge.workspace.FileData
|
||||||
|
import space.kscience.snark.html.*
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
|
//public fun CommonAttributeGroupFacade.css(block: CssBuilder.() -> Unit) {
|
||||||
|
// style = CssBuilder().block().toString()
|
||||||
|
//}
|
||||||
|
|
||||||
|
internal class KtorSiteContext(
|
||||||
|
override val context: Context,
|
||||||
|
override val siteMeta: Meta,
|
||||||
|
override val path: List<String>,
|
||||||
|
override val siteRoute: Name,
|
||||||
|
override val parent: SiteContext?,
|
||||||
|
private val ktorRoute: Route,
|
||||||
|
) : SiteContext, ContextAware {
|
||||||
|
|
||||||
|
|
||||||
|
override fun static(route: Name, data: Data<Binary>) {
|
||||||
|
data.meta[FileData.FILE_PATH_KEY]?.string?.let {
|
||||||
|
val file = try {
|
||||||
|
Path.of(it).toFile()
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
//failure,
|
||||||
|
logger.error { "File $it could not be converted to java.io.File" }
|
||||||
|
return@let
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileName = route.toWebPath()
|
||||||
|
ktorRoute.staticFiles(fileName, file)
|
||||||
|
//success, don't do anything else
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type != typeOf<Binary>()) error("Can't directly serve file of type ${data.type}")
|
||||||
|
ktorRoute.get(route.toWebPath()) {
|
||||||
|
val binary = data.await()
|
||||||
|
val extension = data.meta[FileData.FILE_EXTENSION_KEY]?.string?.let { ".$it" } ?: ""
|
||||||
|
val contentType: ContentType = extension
|
||||||
|
.let(ContentType::fromFileExtension)
|
||||||
|
.firstOrNull()
|
||||||
|
?: ContentType.Any
|
||||||
|
call.respondBytes(contentType = contentType) {
|
||||||
|
//TODO optimize using streaming
|
||||||
|
binary.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private class KtorPageContext(
|
||||||
|
override val site: KtorSiteContext,
|
||||||
|
override val host: Url,
|
||||||
|
override val pageRoute: Name,
|
||||||
|
override val pageMeta: Meta,
|
||||||
|
) : PageContext {
|
||||||
|
|
||||||
|
override fun resolvePageRef(
|
||||||
|
pageName: Name,
|
||||||
|
targetSite: SiteContext,
|
||||||
|
): String {
|
||||||
|
return if (pageName.endsWith(SiteContext.INDEX_PAGE_TOKEN)) {
|
||||||
|
resolveRef(pageName.cutLast().toWebPath(), targetSite)
|
||||||
|
} else {
|
||||||
|
resolveRef(pageName.toWebPath(), targetSite)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun page(route: Name, data: DataTree<*>?, pageMeta: Meta, content: HtmlPage) {
|
||||||
|
ktorRoute.get(route.toWebPath()) {
|
||||||
|
val request = call.request
|
||||||
|
//substitute host for url for backwards calls
|
||||||
|
// val url = URLBuilder(baseUrl).apply {
|
||||||
|
// protocol = URLProtocol.createOrDefault(request.origin.scheme)
|
||||||
|
// host = request.origin.serverHost
|
||||||
|
// port = request.origin.serverPort
|
||||||
|
// }
|
||||||
|
|
||||||
|
val hostUrl = URLBuilder().apply {
|
||||||
|
protocol = URLProtocol.createOrDefault(request.origin.scheme)
|
||||||
|
host = request.origin.serverHost
|
||||||
|
port = request.origin.serverPort
|
||||||
|
}
|
||||||
|
|
||||||
|
val modifiedPageMeta = pageMeta.copy {
|
||||||
|
"host" put hostUrl.buildString()
|
||||||
|
"path" put path.map { it.asValue() }.asValue()
|
||||||
|
"route" put (siteRoute + route).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
val pageContext = KtorPageContext(
|
||||||
|
site = this@KtorSiteContext,
|
||||||
|
host = hostUrl.build(),
|
||||||
|
pageRoute = siteRoute + route,
|
||||||
|
pageMeta = Laminate(modifiedPageMeta, siteMeta)
|
||||||
|
)
|
||||||
|
//render page in suspend environment
|
||||||
|
val html = HtmlPage.createHtmlString(pageContext, data, content)
|
||||||
|
|
||||||
|
call.respond(TextContent(html, ContentType.Text.Html.withCharset(Charsets.UTF_8), HttpStatusCode.OK))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun route(route: Name, data: DataTree<*>?, siteMeta: Meta, content: HtmlSite) {
|
||||||
|
val siteContext = SiteContextWithData(
|
||||||
|
KtorSiteContext(
|
||||||
|
context,
|
||||||
|
siteMeta = Laminate(siteMeta, this@KtorSiteContext.siteMeta),
|
||||||
|
path = path,
|
||||||
|
siteRoute = route,
|
||||||
|
parent,
|
||||||
|
ktorRoute = ktorRoute.createRouteFromPath(route.toWebPath())
|
||||||
|
),
|
||||||
|
data ?: DataTree.EMPTY
|
||||||
|
)
|
||||||
|
with(content) {
|
||||||
|
with(siteContext) {
|
||||||
|
renderSite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun site(route: Name, data: DataTree<*>?, siteMeta: Meta, content: HtmlSite) {
|
||||||
|
val siteContext = SiteContextWithData(
|
||||||
|
KtorSiteContext(
|
||||||
|
context,
|
||||||
|
siteMeta = Laminate(siteMeta, this@KtorSiteContext.siteMeta),
|
||||||
|
path = path + route.tokens.map { it.toStringUnescaped() },
|
||||||
|
siteRoute = Name.EMPTY,
|
||||||
|
this,
|
||||||
|
ktorRoute = ktorRoute.createRouteFromPath(route.toWebPath())
|
||||||
|
),
|
||||||
|
data ?: DataTree.EMPTY
|
||||||
|
)
|
||||||
|
with(content) {
|
||||||
|
with(siteContext) {
|
||||||
|
renderSite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun Route.site(
|
||||||
|
context: Context,
|
||||||
|
data: DataTree<*>?,
|
||||||
|
path: List<String> = emptyList(),
|
||||||
|
siteMeta: Meta = data?.meta ?: Meta.EMPTY,
|
||||||
|
content: HtmlSite,
|
||||||
|
) {
|
||||||
|
val siteContext = SiteContextWithData(
|
||||||
|
KtorSiteContext(context, siteMeta, path = path, siteRoute = Name.EMPTY, null, this@Route),
|
||||||
|
data ?: DataTree.EMPTY
|
||||||
|
)
|
||||||
|
with(content) {
|
||||||
|
with(siteContext) {
|
||||||
|
renderSite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//
|
||||||
|
//public suspend fun Application.site(
|
||||||
|
// context: Context,
|
||||||
|
// data: DataSet<*>,
|
||||||
|
// baseUrl: String = "",
|
||||||
|
// siteMeta: Meta = data.meta,
|
||||||
|
// content: HtmlSite,
|
||||||
|
//) {
|
||||||
|
// routing {}.site(context, data, baseUrl, siteMeta, content)
|
||||||
|
//
|
||||||
|
//}
|
@ -9,31 +9,6 @@ import java.time.LocalDateTime
|
|||||||
import kotlin.io.path.*
|
import kotlin.io.path.*
|
||||||
|
|
||||||
|
|
||||||
//public fun KtorSiteBuilder.extractResources(uri: URI, targetPath: Path): Path {
|
|
||||||
// if (Files.isDirectory(targetPath)) {
|
|
||||||
// logger.info { "Using existing data directory at $targetPath." }
|
|
||||||
// } else {
|
|
||||||
// logger.info { "Copying data from $uri into $targetPath." }
|
|
||||||
// targetPath.createDirectories()
|
|
||||||
// //Copy everything into a temporary directory
|
|
||||||
// FileSystems.newFileSystem(uri, emptyMap<String, Any>()).use { fs ->
|
|
||||||
// val rootPath: Path = fs.provider().getPath(uri)
|
|
||||||
// Files.walk(rootPath).forEach { source: Path ->
|
|
||||||
// if (source.isRegularFile()) {
|
|
||||||
// val relative = source.relativeTo(rootPath).toString()
|
|
||||||
// val destination: Path = targetPath.resolve(relative)
|
|
||||||
// destination.parent.createDirectories()
|
|
||||||
// Files.copy(source, destination)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return targetPath
|
|
||||||
//}
|
|
||||||
//
|
|
||||||
//public fun KtorSiteBuilder.extractResources(resource: String, targetPath: Path): Path =
|
|
||||||
// extractResources(javaClass.getResource(resource)!!.toURI(), targetPath)
|
|
||||||
|
|
||||||
private const val DEPLOY_DATE_FILE = "deployDate"
|
private const val DEPLOY_DATE_FILE = "deployDate"
|
||||||
private const val BUILD_DATE_FILE = "/buildDate"
|
private const val BUILD_DATE_FILE = "/buildDate"
|
||||||
|
|
@ -1,239 +0,0 @@
|
|||||||
package space.kscience.snark.ktor
|
|
||||||
|
|
||||||
import io.ktor.http.ContentType
|
|
||||||
import io.ktor.http.URLBuilder
|
|
||||||
import io.ktor.http.URLProtocol
|
|
||||||
import io.ktor.http.fromFileExtension
|
|
||||||
import io.ktor.server.application.Application
|
|
||||||
import io.ktor.server.application.call
|
|
||||||
import io.ktor.server.html.respondHtml
|
|
||||||
import io.ktor.server.http.content.staticFiles
|
|
||||||
import io.ktor.server.plugins.origin
|
|
||||||
import io.ktor.server.response.respondBytes
|
|
||||||
import io.ktor.server.routing.Route
|
|
||||||
import io.ktor.server.routing.createRouteFromPath
|
|
||||||
import io.ktor.server.routing.get
|
|
||||||
import io.ktor.server.routing.routing
|
|
||||||
import kotlinx.css.CssBuilder
|
|
||||||
import kotlinx.html.CommonAttributeGroupFacade
|
|
||||||
import kotlinx.html.HTML
|
|
||||||
import kotlinx.html.style
|
|
||||||
import space.kscience.dataforge.context.error
|
|
||||||
import space.kscience.dataforge.context.logger
|
|
||||||
import space.kscience.dataforge.data.DataTree
|
|
||||||
import space.kscience.dataforge.data.DataTreeItem
|
|
||||||
import space.kscience.dataforge.data.await
|
|
||||||
import space.kscience.dataforge.data.getItem
|
|
||||||
import space.kscience.dataforge.io.Binary
|
|
||||||
import space.kscience.dataforge.io.toByteArray
|
|
||||||
import space.kscience.dataforge.meta.*
|
|
||||||
import space.kscience.dataforge.names.Name
|
|
||||||
import space.kscience.dataforge.names.cutLast
|
|
||||||
import space.kscience.dataforge.names.endsWith
|
|
||||||
import space.kscience.dataforge.names.plus
|
|
||||||
import space.kscience.dataforge.workspace.FileData
|
|
||||||
import space.kscience.snark.html.SiteBuilder
|
|
||||||
import space.kscience.snark.html.SnarkHtmlPlugin
|
|
||||||
import space.kscience.snark.html.WebPage
|
|
||||||
import space.kscience.snark.html.toWebPath
|
|
||||||
import java.nio.file.Path
|
|
||||||
import kotlin.contracts.InvocationKind
|
|
||||||
import kotlin.contracts.contract
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
public fun CommonAttributeGroupFacade.css(block: CssBuilder.() -> Unit) {
|
|
||||||
style = CssBuilder().block().toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
public class KtorSiteBuilder(
|
|
||||||
override val snark: SnarkHtmlPlugin,
|
|
||||||
override val data: DataTree<*>,
|
|
||||||
override val siteMeta: Meta,
|
|
||||||
private val baseUrl: String,
|
|
||||||
override val route: Name,
|
|
||||||
private val ktorRoute: Route,
|
|
||||||
) : SiteBuilder {
|
|
||||||
|
|
||||||
private fun files(item: DataTreeItem<Any>, routeName: Name) {
|
|
||||||
//try using direct file rendering
|
|
||||||
item.meta[FileData.FILE_PATH_KEY]?.string?.let {
|
|
||||||
val file = try {
|
|
||||||
Path.of(it).toFile()
|
|
||||||
} catch (ex: Exception) {
|
|
||||||
//failure,
|
|
||||||
logger.error { "File $it could not be converted to java.io.File" }
|
|
||||||
return@let
|
|
||||||
}
|
|
||||||
|
|
||||||
val fileName = routeName.toWebPath()
|
|
||||||
ktorRoute.staticFiles(fileName, file)
|
|
||||||
//success, don't do anything else
|
|
||||||
return@files
|
|
||||||
}
|
|
||||||
when (item) {
|
|
||||||
is DataTreeItem.Leaf -> {
|
|
||||||
val datum = item.data
|
|
||||||
if (datum.type != typeOf<Binary>()) error("Can't directly serve file of type ${item.data.type}")
|
|
||||||
ktorRoute.get(routeName.toWebPath()) {
|
|
||||||
val binary = datum.await() as Binary
|
|
||||||
val extension = item.meta[FileData.FILE_EXTENSION_KEY]?.string?.let { ".$it" } ?: ""
|
|
||||||
val contentType: ContentType = extension
|
|
||||||
.let(ContentType::fromFileExtension)
|
|
||||||
.firstOrNull()
|
|
||||||
?: ContentType.Any
|
|
||||||
call.respondBytes(contentType = contentType) {
|
|
||||||
//TODO optimize using streaming
|
|
||||||
binary.toByteArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is DataTreeItem.Node -> {
|
|
||||||
item.tree.items.forEach { (token, childItem) ->
|
|
||||||
files(childItem, routeName + token)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun static(dataName: Name, routeName: Name) {
|
|
||||||
val item: DataTreeItem<Any> = data.getItem(dataName) ?: error("Data with name $dataName is not resolved")
|
|
||||||
files(item, routeName)
|
|
||||||
}
|
|
||||||
//
|
|
||||||
// override fun file(file: Path, webPath: String) {
|
|
||||||
// if (file.isDirectory()) {
|
|
||||||
// ktorRoute.static(webPath) {
|
|
||||||
// //TODO check non-standard FS and convert
|
|
||||||
// files(file.toFile())
|
|
||||||
// }
|
|
||||||
// } else if (webPath.isBlank()) {
|
|
||||||
// error("Can't mount file to an empty route")
|
|
||||||
// } else {
|
|
||||||
// ktorRoute.file(webPath, file.toFile())
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// override fun file(dataName: Name, webPath: String) {
|
|
||||||
// val fileData = data[dataName]
|
|
||||||
// if(fileData is FileData){
|
|
||||||
// ktorRoute.file(webPath)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) {
|
|
||||||
ref
|
|
||||||
} else if (ref.isEmpty()) {
|
|
||||||
baseUrl
|
|
||||||
} else {
|
|
||||||
"${baseUrl.removeSuffix("/")}/$ref"
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private inner class KtorWebPage(
|
|
||||||
val pageBaseUrl: String,
|
|
||||||
override val pageMeta: Meta,
|
|
||||||
) : WebPage {
|
|
||||||
override val snark: SnarkHtmlPlugin get() = this@KtorSiteBuilder.snark
|
|
||||||
override val data: DataTree<*> get() = this@KtorSiteBuilder.data
|
|
||||||
|
|
||||||
override fun resolveRef(ref: String): String = this@KtorSiteBuilder.resolveRef(pageBaseUrl, ref)
|
|
||||||
|
|
||||||
override fun resolvePageRef(
|
|
||||||
pageName: Name,
|
|
||||||
relative: Boolean,
|
|
||||||
): String {
|
|
||||||
val fullPageName = if (relative) this@KtorSiteBuilder.route + pageName else pageName
|
|
||||||
return if (fullPageName.endsWith(SiteBuilder.INDEX_PAGE_TOKEN)) {
|
|
||||||
resolveRef(fullPageName.cutLast().toWebPath())
|
|
||||||
} else {
|
|
||||||
resolveRef(fullPageName.toWebPath())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun page(route: Name, pageMeta: Meta, content: context(HTML, WebPage) () -> Unit) {
|
|
||||||
ktorRoute.get(route.toWebPath()) {
|
|
||||||
call.respondHtml {
|
|
||||||
val request = call.request
|
|
||||||
//substitute host for url for backwards calls
|
|
||||||
val url = URLBuilder(baseUrl).apply {
|
|
||||||
protocol = URLProtocol.createOrDefault(request.origin.scheme)
|
|
||||||
host = request.origin.serverHost
|
|
||||||
port = request.origin.serverPort
|
|
||||||
}
|
|
||||||
|
|
||||||
val modifiedPageMeta = pageMeta.toMutableMeta().apply {
|
|
||||||
"name" put route.toString()
|
|
||||||
"url" put url.buildString()
|
|
||||||
}
|
|
||||||
|
|
||||||
val pageBuilder = KtorWebPage(url.buildString(), Laminate(modifiedPageMeta, siteMeta))
|
|
||||||
content(this, pageBuilder)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun route(
|
|
||||||
routeName: Name,
|
|
||||||
dataOverride: DataTree<*>?,
|
|
||||||
routeMeta: Meta,
|
|
||||||
): SiteBuilder = KtorSiteBuilder(
|
|
||||||
snark = snark,
|
|
||||||
data = dataOverride ?: data,
|
|
||||||
siteMeta = Laminate(routeMeta, siteMeta),
|
|
||||||
baseUrl = baseUrl,
|
|
||||||
route = this.route + routeName,
|
|
||||||
ktorRoute = ktorRoute.createRouteFromPath(routeName.toWebPath())
|
|
||||||
)
|
|
||||||
|
|
||||||
override fun site(
|
|
||||||
routeName: Name,
|
|
||||||
dataOverride: DataTree<*>?,
|
|
||||||
routeMeta: Meta,
|
|
||||||
): SiteBuilder = KtorSiteBuilder(
|
|
||||||
snark = snark,
|
|
||||||
data = dataOverride ?: data,
|
|
||||||
siteMeta = Laminate(routeMeta, siteMeta),
|
|
||||||
baseUrl = resolveRef(baseUrl, routeName.toWebPath()),
|
|
||||||
route = Name.EMPTY,
|
|
||||||
ktorRoute = ktorRoute.createRouteFromPath(routeName.toWebPath())
|
|
||||||
)
|
|
||||||
|
|
||||||
//
|
|
||||||
// override fun resourceFile(resourcesPath: String, webPath: String) {
|
|
||||||
// ktorRoute.resource(resourcesPath, resourcesPath)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// override fun resourceDirectory(resourcesPath: String) {
|
|
||||||
// ktorRoute.resources(resourcesPath)
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Route.site(
|
|
||||||
snarkHtmlPlugin: SnarkHtmlPlugin,
|
|
||||||
data: DataTree<*>,
|
|
||||||
baseUrl: String = "",
|
|
||||||
siteMeta: Meta = data.meta,
|
|
||||||
block: KtorSiteBuilder.() -> Unit,
|
|
||||||
) {
|
|
||||||
contract {
|
|
||||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
|
||||||
}
|
|
||||||
block(KtorSiteBuilder(snarkHtmlPlugin, data, siteMeta, baseUrl, route = Name.EMPTY, this@Route))
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun Application.site(
|
|
||||||
snark: SnarkHtmlPlugin,
|
|
||||||
data: DataTree<*>,
|
|
||||||
baseUrl: String = "",
|
|
||||||
siteMeta: Meta = data.meta,
|
|
||||||
block: SiteBuilder.() -> Unit,
|
|
||||||
) {
|
|
||||||
contract {
|
|
||||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
|
||||||
}
|
|
||||||
routing {
|
|
||||||
site(snark, data, baseUrl, siteMeta, block)
|
|
||||||
}
|
|
||||||
}
|
|
66
snark-pandoc/README.md
Normal file
66
snark-pandoc/README.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
## Examples
|
||||||
|
### Simple converting
|
||||||
|
Convert from INPUT_FILE to OUTPUT_FILE:
|
||||||
|
```java
|
||||||
|
PandocWrapper wrapper = new PandocWrapper();
|
||||||
|
wrapper.use(p -> {
|
||||||
|
var command = new PandocCommandBuilder(List.of(INPUT_FILE), OUTPUT_FILE);
|
||||||
|
PandocWrapper.execute(command);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Equal to:
|
||||||
|
```
|
||||||
|
pandoc --output=OUTPUT_FILE INPUT_FILE
|
||||||
|
```
|
||||||
|
### Convert and set formats
|
||||||
|
Convert from INPUT_FILE to OUTPUT_FILE and set INPUT_FORMAT and OUTPUT_FORMAT:
|
||||||
|
```java
|
||||||
|
PandocWrapper wrapper = new PandocWrapper();
|
||||||
|
wrapper.use(p -> {
|
||||||
|
var command = new PandocCommandBuilder(List.of(INPUT_FILE), OUTPUT_FILE);
|
||||||
|
command.formatForm(INPUT_FORMAT);
|
||||||
|
command.formatTo(OUTPUT_FORMAT);
|
||||||
|
PandocWrapper.execute(command);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Equal to:
|
||||||
|
```
|
||||||
|
pandoc --output=OUTPUT_FILE --from=INPUT_FORMAT --to=OUTPUT_FORMAT INPUT_FILE
|
||||||
|
```
|
||||||
|
### Converting with options
|
||||||
|
Convert from INPUT_FILE to standalone OUTPUT_FILE and set variable KEY to VALUE :
|
||||||
|
```java
|
||||||
|
PandocWrapper wrapper = new PandocWrapper();
|
||||||
|
wrapper.use(p -> {
|
||||||
|
var command = new PandocCommandBuilder(List.of(INPUT_FILE), OUTPUT_FILE);
|
||||||
|
command.standalone();
|
||||||
|
command.setVariable(KEY, VALUE);
|
||||||
|
PandocWrapper.execute(command);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Equal to:
|
||||||
|
```
|
||||||
|
pandoc --output=OUTPUT_FILE --standalone --variable=KEY:VALUE INPUT_FILE
|
||||||
|
```
|
||||||
|
|
||||||
|
### Write output from pandoc to file
|
||||||
|
Receive possible input formats in OUTPUT_FILE:
|
||||||
|
```java
|
||||||
|
PandocWrapper wrapper = new PandocWrapper();
|
||||||
|
wrapper.use(p -> {
|
||||||
|
var command = new PandocCommandBuilder();
|
||||||
|
command.getInputFormats();
|
||||||
|
PandocWrapper.execute(command, OUTPUT_FILE);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
Then in OUTPUT_FILE will be a list supported input formats, one per line.
|
||||||
|
|
||||||
|
### Write errors from pandoc to file
|
||||||
|
Receive all from error stream and exit code in ERROR_FILE and output in OUTPUT_FILE:
|
||||||
|
```java
|
||||||
|
PandocWrapper wrapper = new PandocWrapper();
|
||||||
|
wrapper.use(p -> {
|
||||||
|
var command = new PandocCommandBuilder(List.of(INPUT_FILE), OUTPUT_FILE);
|
||||||
|
PandocWrapper.execute(command, OUTPUT_FILE, ERROR_FILE);
|
||||||
|
});
|
||||||
|
```
|
20
snark-pandoc/build.gradle.kts
Normal file
20
snark-pandoc/build.gradle.kts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
plugins {
|
||||||
|
id("space.kscience.gradle.mpp")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
kscience {
|
||||||
|
useSerialization {
|
||||||
|
json()
|
||||||
|
}
|
||||||
|
jvm()
|
||||||
|
jvmMain {
|
||||||
|
api(spclibs.slf4j)
|
||||||
|
implementation("org.apache.commons:commons-exec:1.3")
|
||||||
|
implementation("org.apache.commons:commons-compress:1.2")
|
||||||
|
}
|
||||||
|
jvmTest{
|
||||||
|
implementation(spclibs.logback.classic)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
package space.kscience.snark.pandoc
|
||||||
|
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
|
||||||
|
public object Pandoc {
|
||||||
|
private val logger: Logger = LoggerFactory.getLogger(Pandoc::class.java)
|
||||||
|
|
||||||
|
private fun getOrInstallPandoc(pandocExecutablePath: Path): String = try {
|
||||||
|
ProcessBuilder("pandoc", "--version").start().waitFor()
|
||||||
|
"pandoc"
|
||||||
|
} catch (ex: IOException) {
|
||||||
|
if (Files.exists(pandocExecutablePath)) {
|
||||||
|
pandocExecutablePath.toAbsolutePath().toString()
|
||||||
|
} else {
|
||||||
|
logger.info("Pandoc not found in the system. Installing it from GitHub")
|
||||||
|
PandocInstaller.installPandoc(pandocExecutablePath).toAbsolutePath().toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call pandoc with options described by commandBuilder.
|
||||||
|
* @param commandBuilder
|
||||||
|
* @return true if successfully false otherwise
|
||||||
|
*/
|
||||||
|
public fun execute(
|
||||||
|
redirectOutput: Path? = null,
|
||||||
|
redirectError: Path? = null,
|
||||||
|
pandocExecutablePath: Path = Path("./pandoc").toAbsolutePath(),
|
||||||
|
commandBuilder: PandocCommandBuilder.() -> Unit,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val path = getOrInstallPandoc(pandocExecutablePath)
|
||||||
|
|
||||||
|
val commandLine = PandocCommandBuilder().apply(commandBuilder).build(path)
|
||||||
|
logger.info("Running pandoc: ${commandLine.joinToString(separator = " ")}")
|
||||||
|
val pandoc = ProcessBuilder(commandLine).apply {
|
||||||
|
if (redirectOutput != null) {
|
||||||
|
redirectOutput(redirectOutput.toFile())
|
||||||
|
}
|
||||||
|
if (redirectError != null) {
|
||||||
|
redirectError(redirectError.toFile())
|
||||||
|
}
|
||||||
|
|
||||||
|
}.start()
|
||||||
|
pandoc.waitFor()
|
||||||
|
|
||||||
|
if (pandoc.exitValue() != 0)
|
||||||
|
error("Non-zero process return for pandoc.")
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,266 @@
|
|||||||
|
package space.kscience.snark.pandoc
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.apache.commons.compress.archivers.ArchiveEntry
|
||||||
|
import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
|
||||||
|
import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
|
||||||
|
import org.apache.commons.exec.OS
|
||||||
|
import org.slf4j.Logger
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import java.io.BufferedInputStream
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.*
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.HttpRequest
|
||||||
|
import java.net.http.HttpResponse
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.attribute.PosixFilePermission
|
||||||
|
import java.time.Duration
|
||||||
|
import java.util.*
|
||||||
|
import java.util.zip.ZipInputStream
|
||||||
|
import kotlin.io.path.Path
|
||||||
|
import kotlin.io.path.inputStream
|
||||||
|
|
||||||
|
internal object PandocInstaller {
|
||||||
|
|
||||||
|
private val log: Logger = LoggerFactory.getLogger(PandocInstaller::class.java)
|
||||||
|
|
||||||
|
|
||||||
|
private const val TIMEOUT_SECONDS = 2
|
||||||
|
private const val ATTEMPTS = 3
|
||||||
|
|
||||||
|
private enum class OSType(val assetSuffix: String, val propertySuffix: String) {
|
||||||
|
WINDOWS("windows-x86_64.zip", "windows"),
|
||||||
|
MAC_OS_AMD("x86_64-macOS.zip", "mac.os.amd"),
|
||||||
|
MAC_OS_ARM("arm64-macOS.zip", "mac.os.arm"),
|
||||||
|
LINUX_ARM("linux-arm64", "linux.arm"),
|
||||||
|
LINUX_AMD("linux-amd64", "linux.amd")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val properties = Properties().apply {
|
||||||
|
load(PandocInstaller.javaClass.getResourceAsStream("/installer.properties")!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Install last released pandoc from github
|
||||||
|
* @return path to executable pandoc
|
||||||
|
* @throws IOException in case incorrect github url or path of installation directory
|
||||||
|
*/
|
||||||
|
public fun installPandoc(targetPath: Path): Path {
|
||||||
|
log.info("Start install")
|
||||||
|
return if (OS.isFamilyMac()) {
|
||||||
|
if (OS.isArch("aarch64")) {
|
||||||
|
installPandoc(OSType.MAC_OS_ARM, targetPath)
|
||||||
|
} else {
|
||||||
|
installPandoc(OSType.MAC_OS_AMD, targetPath)
|
||||||
|
}
|
||||||
|
} else if (OS.isFamilyUnix()) {
|
||||||
|
if (OS.isArch("aarch64")) {
|
||||||
|
installPandoc(OSType.LINUX_ARM, targetPath)
|
||||||
|
} else {
|
||||||
|
installPandoc(OSType.LINUX_AMD, targetPath)
|
||||||
|
}
|
||||||
|
} else if (OS.isFamilyWindows()) {
|
||||||
|
installPandoc(OSType.WINDOWS, targetPath)
|
||||||
|
} else {
|
||||||
|
error("Got unexpected os, could not install pandoc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun installPandoc(os: OSType, targetPath: Path): Path {
|
||||||
|
|
||||||
|
val githubResponse = getGithubUrls()
|
||||||
|
val asset = githubResponse.getAssetByOsSuffix(os.assetSuffix)
|
||||||
|
val currUrl = asset.browserDownloadUrl
|
||||||
|
|
||||||
|
val pandocUrl: URL = URI.create(currUrl).toURL()
|
||||||
|
val fileToInstall: Path = when (os) {
|
||||||
|
OSType.LINUX_AMD, OSType.LINUX_ARM -> Path("$targetPath/pandoc.tar.gz")
|
||||||
|
else -> Path("$targetPath/pandoc.zip")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Start installing pandoc os: {}, url: {}, file: {}",
|
||||||
|
os,
|
||||||
|
pandocUrl,
|
||||||
|
fileToInstall
|
||||||
|
)
|
||||||
|
|
||||||
|
val archivePath = downloadWithRetry(pandocUrl) ?: error("Could not save file from github")
|
||||||
|
val installPath = unPack(archivePath, targetPath, os) ?: error("Could not unzip file")
|
||||||
|
|
||||||
|
|
||||||
|
val pandocExecutablePath = installPath.resolve(
|
||||||
|
properties.getProperty("path.to.pandoc." + os.propertySuffix).replace(
|
||||||
|
"{version}",
|
||||||
|
githubResponse.tagName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (os == OSType.LINUX_AMD || os == OSType.LINUX_ARM) {
|
||||||
|
Files.setPosixFilePermissions(pandocExecutablePath, setOf(PosixFilePermission.GROUP_EXECUTE))
|
||||||
|
}
|
||||||
|
|
||||||
|
return pandocExecutablePath
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Downloads from a (http/https) URL and saves to a file.
|
||||||
|
* @param target File to write. Parent directory will be created if necessary
|
||||||
|
* @param url http/https url to connect
|
||||||
|
* @param secsConnectTimeout Seconds to wait for connection establishment
|
||||||
|
* @param secsReadTimeout Read timeout in seconds - trasmission will abort if it freezes more than this
|
||||||
|
* @return true if successfully save file and false if:
|
||||||
|
* connection interrupted, timeout (but something was read)
|
||||||
|
* server error (500...)
|
||||||
|
* could not connect: connection timeout java.net.SocketTimeoutException
|
||||||
|
* could not connect: java.net.ConnectException
|
||||||
|
* could not resolve host (bad host, or no internet - no dns)
|
||||||
|
* @throws IOException Only if URL is malformed or if could not create the file
|
||||||
|
* @throws FileNotFoundException if did not find file for save
|
||||||
|
*/
|
||||||
|
@Throws(IOException::class)
|
||||||
|
private fun downloadUrl(
|
||||||
|
target: Path,
|
||||||
|
url: URL,
|
||||||
|
secsConnectTimeout: Int,
|
||||||
|
secsReadTimeout: Int,
|
||||||
|
): Path? {
|
||||||
|
Files.createDirectories(target.parent) // make sure parent dir exists , this can throw exception
|
||||||
|
val conn = url.openConnection() // can throw exception if bad url
|
||||||
|
if (secsConnectTimeout > 0) {
|
||||||
|
conn.connectTimeout = secsConnectTimeout * 1000
|
||||||
|
}
|
||||||
|
if (secsReadTimeout > 0) {
|
||||||
|
conn.readTimeout = secsReadTimeout * 1000
|
||||||
|
}
|
||||||
|
var ret = true
|
||||||
|
var somethingRead = false
|
||||||
|
try {
|
||||||
|
conn.getInputStream().use { `is` ->
|
||||||
|
BufferedInputStream(`is`).use { `in` ->
|
||||||
|
Files.newOutputStream(target).use { fout ->
|
||||||
|
val data = ByteArray(8192)
|
||||||
|
var count: Int
|
||||||
|
while ((`in`.read(data).also { count = it }) > 0) {
|
||||||
|
somethingRead = true
|
||||||
|
fout.write(data, 0, count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
} catch (e: IOException) {
|
||||||
|
var httpcode = 999
|
||||||
|
try {
|
||||||
|
httpcode = (conn as HttpURLConnection).responseCode
|
||||||
|
} catch (ee: Exception) {
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e is FileNotFoundException) {
|
||||||
|
throw FileNotFoundException("Did not found file for install")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (somethingRead && e is SocketTimeoutException) {
|
||||||
|
log.error("Read something, but connection interrupted: {}", e.message, e)
|
||||||
|
} else if (httpcode >= 400 && httpcode < 600) {
|
||||||
|
log.error("Got server error, httpcode: {}", httpcode)
|
||||||
|
} else if (e is SocketTimeoutException) {
|
||||||
|
log.error("Connection timeout: {}", e.message, e)
|
||||||
|
} else if (e is ConnectException) {
|
||||||
|
log.error("Could not connect: {}", e.message, e)
|
||||||
|
} else if (e is UnknownHostException) {
|
||||||
|
log.error("Could not resolve host: {}", e.message, e)
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun downloadWithRetry(url: URL): Path? {
|
||||||
|
val targetPath = Files.createTempFile("pandoc", ".tmp")
|
||||||
|
log.info("Downloading pandoc to $targetPath")
|
||||||
|
|
||||||
|
repeat(ATTEMPTS) {
|
||||||
|
return downloadUrl(
|
||||||
|
targetPath,
|
||||||
|
url,
|
||||||
|
TIMEOUT_SECONDS,
|
||||||
|
TIMEOUT_SECONDS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unPack(sourcePath: Path, targetPath: Path, os: OSType): Path? {
|
||||||
|
try {
|
||||||
|
when (os) {
|
||||||
|
OSType.LINUX_AMD, OSType.LINUX_ARM -> unTarGz(sourcePath, targetPath)
|
||||||
|
|
||||||
|
else -> unZip(sourcePath, targetPath)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
log.error("Could not perform unpacking: {}", e.message, e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return targetPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unTarGz(sourcePath: Path, targetDir: Path) {
|
||||||
|
TarArchiveInputStream(
|
||||||
|
GzipCompressorInputStream(
|
||||||
|
BufferedInputStream(Files.newInputStream(sourcePath))
|
||||||
|
)
|
||||||
|
).use { tarIn ->
|
||||||
|
var archiveEntry: ArchiveEntry
|
||||||
|
while ((tarIn.nextEntry.also { archiveEntry = it }) != null) {
|
||||||
|
val pathEntryOutput = targetDir.resolve(archiveEntry.name)
|
||||||
|
if (archiveEntry.isDirectory) {
|
||||||
|
Files.createDirectory(pathEntryOutput)
|
||||||
|
} else {
|
||||||
|
Files.copy(tarIn, pathEntryOutput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unZip(sourcePath: Path, targetDir: Path) {
|
||||||
|
ZipInputStream(sourcePath.inputStream()).use { zis ->
|
||||||
|
do {
|
||||||
|
val entry = zis.nextEntry
|
||||||
|
if (entry == null) continue
|
||||||
|
val pathEntryOutput = targetDir.resolve(entry.name)
|
||||||
|
if (entry.isDirectory) {
|
||||||
|
Files.createDirectories(pathEntryOutput)
|
||||||
|
} else {
|
||||||
|
Files.createDirectories(pathEntryOutput.parent)
|
||||||
|
Files.copy(zis, pathEntryOutput)
|
||||||
|
}
|
||||||
|
zis.closeEntry()
|
||||||
|
} while (entry != null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getGithubUrls(): ResponseDto {
|
||||||
|
val uri = URI.create(properties.getProperty("github.url"))
|
||||||
|
val client = HttpClient.newHttpClient()
|
||||||
|
val request = HttpRequest
|
||||||
|
.newBuilder()
|
||||||
|
.uri(uri)
|
||||||
|
.version(HttpClient.Version.HTTP_2)
|
||||||
|
.timeout(Duration.ofMinutes(1))
|
||||||
|
.header("Accept", "application/vnd.github+json")
|
||||||
|
.GET()
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
|
||||||
|
log.info("Got response from github, status: {}", response.statusCode())
|
||||||
|
|
||||||
|
return Json { ignoreUnknownKeys = true }.decodeFromString(ResponseDto.serializer(), response.body())
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
package space.kscience.snark.pandoc
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response from github/releases/latest
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
internal class ResponseDto(
|
||||||
|
val assets: Array<AssetDto>,
|
||||||
|
@SerialName("tag_name") val tagName: String,
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* @param osSuffix
|
||||||
|
* @return asset appropriate to os
|
||||||
|
*/
|
||||||
|
fun getAssetByOsSuffix(osSuffix: String?): AssetDto {
|
||||||
|
for (asset in assets) {
|
||||||
|
if (asset.name.contains(osSuffix!!)) {
|
||||||
|
return asset
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw IllegalArgumentException("Unexpected osSuffix")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
public class AssetDto(
|
||||||
|
@SerialName("browser_download_url") val browserDownloadUrl: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
8
snark-pandoc/src/jvmMain/resources/installer.properties
Normal file
8
snark-pandoc/src/jvmMain/resources/installer.properties
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
path.to.pandoc.mac.os.arm=/pandoc-{version}-arm64/bin/pandoc
|
||||||
|
path.to.pandoc.mac.os.amd=/pandoc-{version}-x86_64/bin/pandoc
|
||||||
|
path.to.pandoc.windows=/pandoc-{version}/pandoc.exe
|
||||||
|
path.to.pandoc.linux.amd=/pandoc-{version}/bin/pandoc
|
||||||
|
path.to.pandoc.linux.arm=/pandoc-{version}/bin/pandoc
|
||||||
|
|
||||||
|
github.url=https://api.github.com/repos/jgm/pandoc/releases/latest
|
||||||
|
|
105
snark-pandoc/src/jvmTest/kotlin/PandocTest.kt
Normal file
105
snark-pandoc/src/jvmTest/kotlin/PandocTest.kt
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import space.kscience.snark.pandoc.Pandoc
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.isDirectory
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.io.path.writeBytes
|
||||||
|
import kotlin.test.assertContains
|
||||||
|
import kotlin.test.assertFails
|
||||||
|
|
||||||
|
class PandocTest {
|
||||||
|
@Test
|
||||||
|
fun when_gotPandocAndCorrectArgs_doConverting() {
|
||||||
|
|
||||||
|
val inputFile = Files.createTempFile("snark-pandoc", "first_test.md")
|
||||||
|
inputFile.writeBytes(javaClass.getResourceAsStream("/first_test.md")!!.readAllBytes())
|
||||||
|
val outputFile = Files.createTempFile("snark-pandoc", "output1.tex")
|
||||||
|
|
||||||
|
Pandoc.execute {
|
||||||
|
addInputFile(inputFile)
|
||||||
|
outputFile(outputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(outputFile.exists())
|
||||||
|
|
||||||
|
val result = outputFile.readText()
|
||||||
|
|
||||||
|
assertContains(result, "Some simple text")
|
||||||
|
assertContains(result, "\\subsection{Copy elision}")
|
||||||
|
assertContains(result, "return")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun when_gotPandocAndNotExistsFromFile_then_error() {
|
||||||
|
|
||||||
|
val outputFile = Files.createTempFile("snark-pandoc", "output2.tex")
|
||||||
|
val notExistsFile = Path.of("./src/test/testing_directory/non_exists_test.md")
|
||||||
|
assertFalse(notExistsFile.exists())
|
||||||
|
assertFails {
|
||||||
|
Pandoc.execute {
|
||||||
|
addInputFile(notExistsFile)
|
||||||
|
outputFile(outputFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun when_gotPandocAndPassDirectory_then_error() {
|
||||||
|
val tempDir = Files.createTempDirectory("snark-pandoc")
|
||||||
|
assertTrue(tempDir.isDirectory())
|
||||||
|
|
||||||
|
val outputFile = Files.createTempFile("snark-pandoc", "output3.tex")
|
||||||
|
|
||||||
|
assertFails {
|
||||||
|
Pandoc.execute {
|
||||||
|
addInputFile(tempDir)
|
||||||
|
outputFile(outputFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun when_askVersionToFile_then_Ok() {
|
||||||
|
val outputFile = Files.createTempFile("snark-pandoc", "output4.tex")
|
||||||
|
|
||||||
|
val res = Pandoc.execute(redirectOutput = outputFile) {
|
||||||
|
getVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
val fileContent = outputFile.readText()
|
||||||
|
assertContains(fileContent, "pandoc")
|
||||||
|
assertContains(fileContent, "This is free software")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun when_error_then_writeToErrorStream() {
|
||||||
|
val inputFile = Files.createTempFile("snark-pandoc", "simple.txt")
|
||||||
|
inputFile.writeBytes(javaClass.getResourceAsStream("/simple.txt")!!.readAllBytes())
|
||||||
|
val outputFile = Files.createTempFile("snark-pandoc", "output.txt")
|
||||||
|
val errorFile = Files.createTempFile("snark-pandoc", "error.txt")
|
||||||
|
|
||||||
|
assertFails {
|
||||||
|
Pandoc.execute(redirectError = errorFile) {
|
||||||
|
addInputFile(inputFile)
|
||||||
|
outputFile(outputFile)
|
||||||
|
formatFrom("txt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertContains(errorFile.readText(), "input format")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// @Test
|
||||||
|
// fun when_installPandoc_thenFindIt() {
|
||||||
|
// PandocInstaller.clearInstallingDirectory()
|
||||||
|
// assertTrue(Pandoc.installPandoc())
|
||||||
|
// assertTrue(Pandoc.isPandocInstalled())
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
15
snark-pandoc/src/jvmTest/resources/first_test.md
Normal file
15
snark-pandoc/src/jvmTest/resources/first_test.md
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
## Copy elision
|
||||||
|
### RVO/NRVO
|
||||||
|
Some simple text
|
||||||
|
```c++
|
||||||
|
A f() {
|
||||||
|
return {5};
|
||||||
|
}
|
||||||
|
|
||||||
|
A g() {
|
||||||
|
A a(5);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
```
|
1
snark-pandoc/src/jvmTest/resources/simple.txt
Normal file
1
snark-pandoc/src/jvmTest/resources/simple.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
hello
|
Loading…
Reference in New Issue
Block a user