Spliting site and the snark project complete
This commit is contained in:
parent
406ac3fb9e
commit
cb60fbcfe2
@ -9,10 +9,10 @@ allprojects {
|
|||||||
if(name!="snark-gradle-plugin") {
|
if(name!="snark-gradle-plugin") {
|
||||||
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
|
freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val dataforgeVersion by extra("0.6.0-dev-9")
|
val dataforgeVersion by extra("0.6.0-dev-10")
|
@ -0,0 +1,40 @@
|
|||||||
|
package space.kscience.snark
|
||||||
|
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.context.Global
|
||||||
|
import space.kscience.dataforge.context.Plugin
|
||||||
|
import space.kscience.dataforge.data.DataSourceBuilder
|
||||||
|
import space.kscience.dataforge.data.DataTree
|
||||||
|
import space.kscience.dataforge.data.DataTreeBuilder
|
||||||
|
import space.kscience.dataforge.meta.MutableMeta
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
|
public class SnarkEnvironment(public val parentContext: Context) {
|
||||||
|
private var _data: DataTree<*>? = null
|
||||||
|
public val data: DataTree<Any> get() = _data ?: DataTree.empty()
|
||||||
|
|
||||||
|
public fun data(builder: DataSourceBuilder<Any>.() -> Unit) {
|
||||||
|
_data = DataTreeBuilder<Any>(typeOf<Any>(), parentContext.coroutineContext).apply(builder)
|
||||||
|
//TODO use node meta
|
||||||
|
}
|
||||||
|
|
||||||
|
public val meta: MutableMeta = MutableMeta()
|
||||||
|
|
||||||
|
public fun meta(block: MutableMeta.() -> Unit) {
|
||||||
|
meta.apply(block)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val _plugins = HashSet<Plugin>()
|
||||||
|
public val plugins: Set<Plugin> get() = _plugins
|
||||||
|
|
||||||
|
public fun registerPlugin(plugin: Plugin) {
|
||||||
|
_plugins.add(plugin)
|
||||||
|
}
|
||||||
|
|
||||||
|
public companion object{
|
||||||
|
public val default: SnarkEnvironment = SnarkEnvironment(Global)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun SnarkEnvironment(parentContext: Context = Global, block: SnarkEnvironment.() -> Unit): SnarkEnvironment =
|
||||||
|
SnarkEnvironment(parentContext).apply(block)
|
@ -4,9 +4,12 @@ import io.ktor.utils.io.core.Input
|
|||||||
import io.ktor.utils.io.core.readBytes
|
import io.ktor.utils.io.core.readBytes
|
||||||
import space.kscience.dataforge.context.Context
|
import space.kscience.dataforge.context.Context
|
||||||
import space.kscience.dataforge.io.IOReader
|
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.meta.Meta
|
||||||
import space.kscience.dataforge.misc.Type
|
import space.kscience.dataforge.misc.Type
|
||||||
import kotlin.reflect.KType
|
import kotlin.reflect.KType
|
||||||
|
import kotlin.reflect.typeOf
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A parser of binary content including priority flag and file extensions
|
* A parser of binary content including priority flag and file extensions
|
||||||
@ -31,4 +34,22 @@ public interface SnarkParser<out R> {
|
|||||||
public const val TYPE: String = "snark.parser"
|
public const val TYPE: String = "snark.parser"
|
||||||
public const val DEFAULT_PRIORITY: Int = 10
|
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())
|
@ -16,7 +16,7 @@ import space.kscience.snark.SnarkContext
|
|||||||
//typealias HtmlFragment = context(PageBuilder, TagConsumer<*>) () -> Unit
|
//typealias HtmlFragment = context(PageBuilder, TagConsumer<*>) () -> Unit
|
||||||
|
|
||||||
public fun interface HtmlFragment {
|
public fun interface HtmlFragment {
|
||||||
public fun TagConsumer<*>.renderFragment(page: Page)
|
public fun TagConsumer<*>.renderFragment(page: WebPage)
|
||||||
//TODO move pageBuilder to a context receiver after KT-52967 is fixed
|
//TODO move pageBuilder to a context receiver after KT-52967 is fixed
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,7 +26,7 @@ public typealias HtmlData = Data<HtmlFragment>
|
|||||||
// Data(HtmlFragment(content), meta)
|
// Data(HtmlFragment(content), meta)
|
||||||
|
|
||||||
|
|
||||||
context(Page) public fun FlowContent.htmlData(data: HtmlData): Unit = runBlocking(Dispatchers.IO) {
|
context(WebPage) public fun FlowContent.htmlData(data: HtmlData): Unit = runBlocking(Dispatchers.IO) {
|
||||||
with(data.await()) { consumer.renderFragment(page) }
|
with(data.await()) { consumer.renderFragment(page) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -4,15 +4,12 @@ import kotlinx.html.HTML
|
|||||||
import space.kscience.dataforge.context.Context
|
import space.kscience.dataforge.context.Context
|
||||||
import space.kscience.dataforge.context.ContextAware
|
import space.kscience.dataforge.context.ContextAware
|
||||||
import space.kscience.dataforge.data.DataTree
|
import space.kscience.dataforge.data.DataTree
|
||||||
import space.kscience.dataforge.data.DataTreeItem
|
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
import space.kscience.dataforge.names.Name
|
import space.kscience.dataforge.names.Name
|
||||||
import space.kscience.dataforge.names.NameToken
|
import space.kscience.dataforge.names.NameToken
|
||||||
import space.kscience.dataforge.names.parseAsName
|
import space.kscience.dataforge.names.parseAsName
|
||||||
import space.kscience.snark.SnarkContext
|
import space.kscience.snark.SnarkContext
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.reflect.KType
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,27 +17,46 @@ import kotlin.reflect.typeOf
|
|||||||
*/
|
*/
|
||||||
public interface SiteBuilder : ContextAware, SnarkContext {
|
public interface SiteBuilder : ContextAware, SnarkContext {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data used for site construction. The type of the data is not limited
|
||||||
|
*/
|
||||||
public val data: DataTree<*>
|
public val data: DataTree<*>
|
||||||
|
|
||||||
public val snark: SnarkPlugin
|
/**
|
||||||
|
* Snark plugin and context used for layout resolution, preprocessors, etc
|
||||||
|
*/
|
||||||
|
public val snark: SnarkHtmlPlugin
|
||||||
|
|
||||||
override val context: Context get() = snark.context
|
override val context: Context get() = snark.context
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site configuration
|
||||||
|
*/
|
||||||
public val siteMeta: Meta
|
public val siteMeta: Meta
|
||||||
|
|
||||||
public fun assetFile(remotePath: String, file: Path)
|
/**
|
||||||
|
* Add a static file or directory to this site/route at [remotePath]
|
||||||
|
*/
|
||||||
|
public fun file(file: Path, remotePath: String = file.fileName.toString())
|
||||||
|
|
||||||
public fun assetDirectory(remotePath: String, directory: Path)
|
/**
|
||||||
|
* Add a static file (single) from resources
|
||||||
|
*/
|
||||||
|
public fun resourceFile(remotePath: String, resourcesPath: String)
|
||||||
|
|
||||||
public fun assetResourceFile(remotePath: String, resourcesPath: String)
|
/**
|
||||||
|
* Add a resource directory to route
|
||||||
|
*/
|
||||||
|
public fun resourceDirectory(resourcesPath: String)
|
||||||
|
|
||||||
public fun assetResourceDirectory(resourcesPath: String)
|
/**
|
||||||
|
* Create a single page at given [route]. If route is empty, create an index page at current route.
|
||||||
public fun page(route: Name = Name.EMPTY, content: context(Page, HTML) () -> Unit)
|
*/
|
||||||
|
public fun page(route: Name = Name.EMPTY, content: context(WebPage, HTML) () -> Unit)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a route with optional data tree override. For example one could use a subtree of the initial tree.
|
* 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
|
* By default, the same data tree is used for route.
|
||||||
*/
|
*/
|
||||||
public fun route(
|
public fun route(
|
||||||
routeName: Name,
|
routeName: Name,
|
||||||
@ -90,11 +106,4 @@ public inline fun SiteBuilder.route(
|
|||||||
// route(route) {
|
// route(route) {
|
||||||
// withData(mountedData).block()
|
// withData(mountedData).block()
|
||||||
// }
|
// }
|
||||||
//}
|
//}
|
||||||
|
|
||||||
//TODO move to DF
|
|
||||||
public fun DataTree.Companion.empty(meta: Meta = Meta.EMPTY): DataTree<Any> = object : DataTree<Any> {
|
|
||||||
override val items: Map<NameToken, DataTreeItem<Any>> get() = emptyMap()
|
|
||||||
override val dataType: KType get() = typeOf<Any>()
|
|
||||||
override val meta: Meta get() = meta
|
|
||||||
}
|
|
@ -30,27 +30,30 @@ internal fun SiteBuilder.assetsFrom(rootMeta: Meta) {
|
|||||||
path?.let { resourcePath ->
|
path?.let { resourcePath ->
|
||||||
//If remote path provided, use a single resource
|
//If remote path provided, use a single resource
|
||||||
remotePath?.let {
|
remotePath?.let {
|
||||||
assetResourceFile(it, resourcePath)
|
resourceFile(it, resourcePath)
|
||||||
return@forEach
|
return@forEach
|
||||||
}
|
}
|
||||||
|
|
||||||
//otherwise use package resources
|
//otherwise use package resources
|
||||||
assetResourceDirectory(resourcePath)
|
resourceDirectory(resourcePath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
rootMeta.getIndexed("file".asName()).forEach { (_, meta) ->
|
rootMeta.getIndexed("file".asName()).forEach { (_, meta) ->
|
||||||
val remotePath by meta.string { error("File remote path is not provided") }
|
val remotePath by meta.string { error("File remote path is not provided") }
|
||||||
val path by meta.string { error("File path is not provided") }
|
val path by meta.string { error("File path is not provided") }
|
||||||
assetFile(remotePath, Path.of(path))
|
file(Path.of(path), remotePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
rootMeta.getIndexed("directory".asName()).forEach { (_, meta) ->
|
rootMeta.getIndexed("directory".asName()).forEach { (_, meta) ->
|
||||||
val path by meta.string { error("Directory path is not provided") }
|
val path by meta.string { error("Directory path is not provided") }
|
||||||
assetDirectory("", Path.of(path))
|
file(Path.of(path), "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render (or don't) given data piece
|
||||||
|
*/
|
||||||
public typealias DataRenderer = SiteBuilder.(name: Name, data: Data<Any>) -> Unit
|
public typealias DataRenderer = SiteBuilder.(name: Name, data: Data<Any>) -> Unit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -114,7 +117,9 @@ public fun SiteBuilder.pages(
|
|||||||
pages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
|
pages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An abstraction to render singular data or a data tree.
|
||||||
|
*/
|
||||||
@Type(SiteLayout.TYPE)
|
@Type(SiteLayout.TYPE)
|
||||||
public fun interface SiteLayout {
|
public fun interface SiteLayout {
|
||||||
|
|
||||||
@ -142,7 +147,9 @@ public fun interface SiteLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default [SiteLayout]. It renders all [HtmlData] pages as t with simple headers via [SiteLayout.defaultDataRenderer]
|
||||||
|
*/
|
||||||
public object DefaultSiteLayout : SiteLayout {
|
public object DefaultSiteLayout : SiteLayout {
|
||||||
context(SiteBuilder) override fun render(item: DataTreeItem<*>) {
|
context(SiteBuilder) override fun render(item: DataTreeItem<*>) {
|
||||||
pages(item)
|
pages(item)
|
||||||
|
@ -3,7 +3,9 @@ package space.kscience.snark.html
|
|||||||
import io.ktor.utils.io.core.readBytes
|
import io.ktor.utils.io.core.readBytes
|
||||||
import space.kscience.dataforge.context.*
|
import space.kscience.dataforge.context.*
|
||||||
import space.kscience.dataforge.data.DataTree
|
import space.kscience.dataforge.data.DataTree
|
||||||
import space.kscience.dataforge.io.*
|
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.YamlMetaFormat
|
||||||
import space.kscience.dataforge.io.yaml.YamlPlugin
|
import space.kscience.dataforge.io.yaml.YamlPlugin
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
@ -15,32 +17,16 @@ import space.kscience.dataforge.names.asName
|
|||||||
import space.kscience.dataforge.names.parseAsName
|
import space.kscience.dataforge.names.parseAsName
|
||||||
import space.kscience.dataforge.workspace.FileData
|
import space.kscience.dataforge.workspace.FileData
|
||||||
import space.kscience.dataforge.workspace.readDataDirectory
|
import space.kscience.dataforge.workspace.readDataDirectory
|
||||||
|
import space.kscience.snark.SnarkEnvironment
|
||||||
import space.kscience.snark.SnarkParser
|
import space.kscience.snark.SnarkParser
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.io.path.extension
|
import kotlin.io.path.extension
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import kotlin.reflect.KType
|
|
||||||
import kotlin.reflect.typeOf
|
|
||||||
|
|
||||||
@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
|
* A plugin used for rendering a [DataTree] as HTML
|
||||||
*/
|
*/
|
||||||
@Suppress("FunctionName")
|
public class SnarkHtmlPlugin : AbstractPlugin() {
|
||||||
public inline fun <reified R : Any> SnarkParser(
|
|
||||||
reader: IOReader<R>,
|
|
||||||
vararg fileExtensions: String,
|
|
||||||
): SnarkParser<R> = SnarkParserWrapper(reader, typeOf<R>(), fileExtensions.toSet())
|
|
||||||
|
|
||||||
public class SnarkPlugin : AbstractPlugin() {
|
|
||||||
private val yaml by require(YamlPlugin)
|
private val yaml by require(YamlPlugin)
|
||||||
public val io: IOPlugin get() = yaml.io
|
public val io: IOPlugin get() = yaml.io
|
||||||
|
|
||||||
@ -54,8 +40,8 @@ public class SnarkPlugin : AbstractPlugin() {
|
|||||||
context.gather(SiteLayout.TYPE, true)
|
context.gather(SiteLayout.TYPE, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val textTransformations: Map<Name, TextTransformation> by lazy {
|
private val textProcessors: Map<Name, TextProcessor> by lazy {
|
||||||
context.gather(TextTransformation.TYPE, true)
|
context.gather(TextProcessor.TYPE, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun siteLayout(layoutMeta: Meta): SiteLayout {
|
internal fun siteLayout(layoutMeta: Meta): SiteLayout {
|
||||||
@ -64,10 +50,10 @@ public class SnarkPlugin : AbstractPlugin() {
|
|||||||
return siteLayouts[layoutName.parseAsName()] ?: error("Layout with name $layoutName not found in $this")
|
return siteLayouts[layoutName.parseAsName()] ?: error("Layout with name $layoutName not found in $this")
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun textTransformation(transformationMeta: Meta): TextTransformation {
|
internal fun textProcessor(transformationMeta: Meta): TextProcessor {
|
||||||
val transformationName = transformationMeta.string
|
val transformationName = transformationMeta.string
|
||||||
?: transformationMeta["name"].string ?: error("Transformation name not defined in $transformationMeta")
|
?: transformationMeta["name"].string ?: error("Transformation name not defined in $transformationMeta")
|
||||||
return textTransformations[transformationName.parseAsName()]
|
return textProcessors[transformationName.parseAsName()]
|
||||||
?: error("Text transformation with name $transformationName not found in $this")
|
?: error("Text transformation with name $transformationName not found in $this")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,17 +67,17 @@ public class SnarkPlugin : AbstractPlugin() {
|
|||||||
"jpg".asName() to SnarkParser(ImageIOReader, "jpg", "jpeg"),
|
"jpg".asName() to SnarkParser(ImageIOReader, "jpg", "jpeg"),
|
||||||
"gif".asName() to SnarkParser(ImageIOReader, "gif"),
|
"gif".asName() to SnarkParser(ImageIOReader, "gif"),
|
||||||
)
|
)
|
||||||
TextTransformation.TYPE -> mapOf(
|
TextProcessor.TYPE -> mapOf(
|
||||||
"basic".asName() to BasicTextTransformation
|
"basic".asName() to BasicTextProcessor
|
||||||
)
|
)
|
||||||
else -> super.content(target)
|
else -> super.content(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
public companion object : PluginFactory<SnarkPlugin> {
|
public companion object : PluginFactory<SnarkHtmlPlugin> {
|
||||||
override val tag: PluginTag = PluginTag("snark")
|
override val tag: PluginTag = PluginTag("snark")
|
||||||
override val type: KClass<out SnarkPlugin> = SnarkPlugin::class
|
override val type: KClass<out SnarkHtmlPlugin> = SnarkHtmlPlugin::class
|
||||||
|
|
||||||
override fun build(context: Context, meta: Meta): SnarkPlugin = SnarkPlugin()
|
override fun build(context: Context, meta: Meta): SnarkHtmlPlugin = SnarkHtmlPlugin()
|
||||||
|
|
||||||
private val byteArrayIOReader = IOReader {
|
private val byteArrayIOReader = IOReader {
|
||||||
readBytes()
|
readBytes()
|
||||||
@ -101,8 +87,21 @@ public class SnarkPlugin : AbstractPlugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load necessary dependencies and return a [SnarkHtmlPlugin] in a finalized context
|
||||||
|
*/
|
||||||
|
public fun SnarkEnvironment.buildHtmlPlugin(): SnarkHtmlPlugin {
|
||||||
|
val context = parentContext.buildContext("snark".asName()) {
|
||||||
|
plugin(SnarkHtmlPlugin)
|
||||||
|
plugins.forEach {
|
||||||
|
plugin(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return context.fetch(SnarkHtmlPlugin)
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(DFExperimental::class)
|
@OptIn(DFExperimental::class)
|
||||||
public fun SnarkPlugin.readDirectory(path: Path): DataTree<Any> = io.readDataDirectory(path) { dataPath, meta ->
|
public fun SnarkHtmlPlugin.readDirectory(path: Path): DataTree<Any> = io.readDataDirectory(path) { dataPath, meta ->
|
||||||
val fileExtension = meta[FileData.META_FILE_EXTENSION_KEY].string ?: dataPath.extension
|
val fileExtension = meta[FileData.META_FILE_EXTENSION_KEY].string ?: dataPath.extension
|
||||||
val parser: SnarkParser<Any> = parsers.values.filter { parser ->
|
val parser: SnarkParser<Any> = parsers.values.filter { parser ->
|
||||||
fileExtension in parser.fileExtensions
|
fileExtension in parser.fileExtensions
|
||||||
@ -110,7 +109,7 @@ public fun SnarkPlugin.readDirectory(path: Path): DataTree<Any> = io.readDataDir
|
|||||||
it.priority
|
it.priority
|
||||||
} ?: run {
|
} ?: run {
|
||||||
logger.warn { "The parser is not found for file $dataPath with meta $meta" }
|
logger.warn { "The parser is not found for file $dataPath with meta $meta" }
|
||||||
SnarkPlugin.byteArraySnarkParser
|
SnarkHtmlPlugin.byteArraySnarkParser
|
||||||
}
|
}
|
||||||
|
|
||||||
parser.reader(context, meta)
|
parser.reader(context, meta)
|
@ -23,9 +23,9 @@ public abstract class SnarkTextParser<R> : SnarkParser<R> {
|
|||||||
override fun parse(context: Context, meta: Meta, bytes: ByteArray): R =
|
override fun parse(context: Context, meta: Meta, bytes: ByteArray): R =
|
||||||
parseText(bytes.decodeToString(), meta)
|
parseText(bytes.decodeToString(), meta)
|
||||||
|
|
||||||
public fun transformText(text: String, meta: Meta, page: Page): String =
|
public fun transformText(text: String, meta: Meta, page: WebPage): String =
|
||||||
meta[TextTransformation.TEXT_TRANSFORMATION_KEY]?.let {
|
meta[TextProcessor.TEXT_TRANSFORMATION_KEY]?.let {
|
||||||
with(page) { page.snark.textTransformation(it).transform(text) }
|
with(page) { page.snark.textProcessor(it).process(text) }
|
||||||
} ?: text
|
} ?: text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ import space.kscience.dataforge.meta.Meta
|
|||||||
import space.kscience.dataforge.meta.withDefault
|
import space.kscience.dataforge.meta.withDefault
|
||||||
import space.kscience.dataforge.names.Name
|
import space.kscience.dataforge.names.Name
|
||||||
import space.kscience.dataforge.names.isEmpty
|
import space.kscience.dataforge.names.isEmpty
|
||||||
|
import space.kscience.snark.SnarkEnvironment
|
||||||
import java.nio.file.Files
|
import java.nio.file.Files
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.contracts.InvocationKind
|
import kotlin.contracts.InvocationKind
|
||||||
@ -15,12 +16,15 @@ import kotlin.contracts.contract
|
|||||||
import kotlin.io.path.*
|
import kotlin.io.path.*
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An implementation of [SiteBuilder] to render site as a static directory [outputPath]
|
||||||
|
*/
|
||||||
internal class StaticSiteBuilder(
|
internal class StaticSiteBuilder(
|
||||||
override val snark: SnarkPlugin,
|
override val snark: SnarkHtmlPlugin,
|
||||||
override val data: DataTree<*>,
|
override val data: DataTree<*>,
|
||||||
override val siteMeta: Meta,
|
override val siteMeta: Meta,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
private val path: Path,
|
private val outputPath: Path,
|
||||||
) : SiteBuilder {
|
) : SiteBuilder {
|
||||||
private fun Path.copyRecursively(target: Path) {
|
private fun Path.copyRecursively(target: Path) {
|
||||||
Files.walk(this).forEach { source: Path ->
|
Files.walk(this).forEach { source: Path ->
|
||||||
@ -32,27 +36,28 @@ internal class StaticSiteBuilder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun assetFile(remotePath: String, file: Path) {
|
override fun file(file: Path, remotePath: String) {
|
||||||
val targetPath = path.resolve(remotePath)
|
val targetPath = outputPath.resolve(remotePath)
|
||||||
targetPath.parent.createDirectories()
|
if(file.isDirectory()){
|
||||||
file.copyTo(targetPath, true)
|
targetPath.parent.createDirectories()
|
||||||
|
file.copyRecursively(targetPath)
|
||||||
|
} else if(remotePath.isBlank()) {
|
||||||
|
error("Can't mount file to an empty route")
|
||||||
|
} else {
|
||||||
|
targetPath.parent.createDirectories()
|
||||||
|
file.copyTo(targetPath, true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun assetDirectory(remotePath: String, directory: Path) {
|
override fun resourceFile(remotePath: String, resourcesPath: String) {
|
||||||
val targetPath = path.resolve(remotePath)
|
val targetPath = outputPath.resolve(remotePath)
|
||||||
targetPath.parent.createDirectories()
|
|
||||||
directory.copyRecursively(targetPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun assetResourceFile(remotePath: String, resourcesPath: String) {
|
|
||||||
val targetPath = path.resolve(remotePath)
|
|
||||||
targetPath.parent.createDirectories()
|
targetPath.parent.createDirectories()
|
||||||
javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyTo(targetPath, true)
|
javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyTo(targetPath, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun assetResourceDirectory(resourcesPath: String) {
|
override fun resourceDirectory(resourcesPath: String) {
|
||||||
path.parent.createDirectories()
|
outputPath.parent.createDirectories()
|
||||||
javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyRecursively(path)
|
javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyRecursively(outputPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) {
|
private fun resolveRef(baseUrl: String, ref: String) = if (baseUrl.isEmpty()) {
|
||||||
@ -63,10 +68,10 @@ internal class StaticSiteBuilder(
|
|||||||
"${baseUrl.removeSuffix("/")}/$ref"
|
"${baseUrl.removeSuffix("/")}/$ref"
|
||||||
}
|
}
|
||||||
|
|
||||||
inner class StaticPage : Page {
|
inner class StaticWebPage : WebPage {
|
||||||
override val data: DataTree<*> get() = this@StaticSiteBuilder.data
|
override val data: DataTree<*> get() = this@StaticSiteBuilder.data
|
||||||
override val pageMeta: Meta get() = this@StaticSiteBuilder.siteMeta
|
override val pageMeta: Meta get() = this@StaticSiteBuilder.siteMeta
|
||||||
override val snark: SnarkPlugin get() = this@StaticSiteBuilder.snark
|
override val snark: SnarkHtmlPlugin get() = this@StaticSiteBuilder.snark
|
||||||
|
|
||||||
|
|
||||||
override fun resolveRef(ref: String): String = resolveRef(baseUrl, ref)
|
override fun resolveRef(ref: String): String = resolveRef(baseUrl, ref)
|
||||||
@ -77,17 +82,17 @@ internal class StaticSiteBuilder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun page(route: Name, content: context(Page, HTML) () -> Unit) {
|
override fun page(route: Name, content: context(WebPage, HTML) () -> Unit) {
|
||||||
val htmlBuilder = createHTML()
|
val htmlBuilder = createHTML()
|
||||||
|
|
||||||
htmlBuilder.html {
|
htmlBuilder.html {
|
||||||
content(StaticPage(), this)
|
content(StaticWebPage(), this)
|
||||||
}
|
}
|
||||||
|
|
||||||
val newPath = if (route.isEmpty()) {
|
val newPath = if (route.isEmpty()) {
|
||||||
path.resolve("index.html")
|
outputPath.resolve("index.html")
|
||||||
} else {
|
} else {
|
||||||
path.resolve(route.toWebPath() + ".html")
|
outputPath.resolve(route.toWebPath() + ".html")
|
||||||
}
|
}
|
||||||
|
|
||||||
newPath.parent.createDirectories()
|
newPath.parent.createDirectories()
|
||||||
@ -108,18 +113,23 @@ internal class StaticSiteBuilder(
|
|||||||
} else {
|
} else {
|
||||||
baseUrl
|
baseUrl
|
||||||
},
|
},
|
||||||
path = path.resolve(routeName.toWebPath())
|
outputPath = outputPath.resolve(routeName.toWebPath())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun SnarkPlugin.renderStatic(
|
/**
|
||||||
|
* Create a static site using given [data] in provided [outputPath].
|
||||||
|
* Use [siteUrl] as a base for all resolved URLs. By default, use [outputPath] absolute path as a base.
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
public fun SnarkEnvironment.static(
|
||||||
outputPath: Path,
|
outputPath: Path,
|
||||||
data: DataTree<*> = DataTree.empty(),
|
|
||||||
siteUrl: String = outputPath.absolutePathString().replace("\\", "/"),
|
siteUrl: String = outputPath.absolutePathString().replace("\\", "/"),
|
||||||
block: SiteBuilder.() -> Unit,
|
block: SiteBuilder.() -> Unit,
|
||||||
) {
|
) {
|
||||||
contract {
|
contract {
|
||||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||||
}
|
}
|
||||||
StaticSiteBuilder(this, data, meta, siteUrl, outputPath).block()
|
val plugin = buildHtmlPlugin()
|
||||||
|
StaticSiteBuilder(plugin, data, meta, siteUrl, outputPath).block()
|
||||||
}
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
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.]*)(?>\("([\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")
|
||||||
|
resolvePageRef(refString)
|
||||||
|
}
|
||||||
|
"pageMeta.get" -> {
|
||||||
|
val nameString = match.groups[2]?.value
|
||||||
|
?: error("resolvePageRef requires a string (quoted) argument")
|
||||||
|
pageMeta[nameString.parseAsName()].string ?: "@null"
|
||||||
|
}
|
||||||
|
else -> match.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
|||||||
package space.kscience.snark.html
|
|
||||||
|
|
||||||
import space.kscience.dataforge.misc.Type
|
|
||||||
import space.kscience.dataforge.names.NameToken
|
|
||||||
|
|
||||||
@Type(TextTransformation.TYPE)
|
|
||||||
public fun interface TextTransformation {
|
|
||||||
context(Page) public fun transform(text: String): String
|
|
||||||
|
|
||||||
public companion object {
|
|
||||||
public const val TYPE: String = "snark.textTransformation"
|
|
||||||
public val TEXT_TRANSFORMATION_KEY: NameToken = NameToken("transformation")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public object BasicTextTransformation : TextTransformation {
|
|
||||||
|
|
||||||
private val regex = "\\\$\\{(\\w*)(?>\\(\"(.*)\"\\))?\\}".toRegex()
|
|
||||||
|
|
||||||
context(Page) override fun transform(text: String): String {
|
|
||||||
return 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")
|
|
||||||
resolvePageRef(refString)
|
|
||||||
}
|
|
||||||
else -> match.value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -17,9 +17,9 @@ context(SnarkContext) public fun Name.toWebPath(): String = tokens.joinToString(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface Page : ContextAware, SnarkContext {
|
public interface WebPage : ContextAware, SnarkContext {
|
||||||
|
|
||||||
public val snark: SnarkPlugin
|
public val snark: SnarkHtmlPlugin
|
||||||
|
|
||||||
override val context: Context get() = snark.context
|
override val context: Context get() = snark.context
|
||||||
|
|
||||||
@ -32,11 +32,11 @@ public interface Page : ContextAware, SnarkContext {
|
|||||||
public fun resolvePageRef(pageName: Name): String
|
public fun resolvePageRef(pageName: Name): String
|
||||||
}
|
}
|
||||||
|
|
||||||
context(Page) public val page: Page get() = this@Page
|
context(WebPage) public val page: WebPage get() = this@WebPage
|
||||||
|
|
||||||
public fun Page.resolvePageRef(pageName: String): String = resolvePageRef(pageName.parseAsName())
|
public fun WebPage.resolvePageRef(pageName: String): String = resolvePageRef(pageName.parseAsName())
|
||||||
|
|
||||||
public val Page.homeRef: String get() = resolvePageRef(SiteBuilder.INDEX_PAGE_TOKEN.asName())
|
public val WebPage.homeRef: String get() = resolvePageRef(SiteBuilder.INDEX_PAGE_TOKEN.asName())
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a Html builder by its full name
|
* Resolve a Html builder by its full name
|
@ -0,0 +1,42 @@
|
|||||||
|
package space.kscience.snark.html
|
||||||
|
|
||||||
|
import space.kscience.dataforge.context.AbstractPlugin
|
||||||
|
import space.kscience.dataforge.context.PluginTag
|
||||||
|
import space.kscience.dataforge.data.DataTreeItem
|
||||||
|
import space.kscience.dataforge.names.Name
|
||||||
|
import space.kscience.dataforge.names.parseAsName
|
||||||
|
import space.kscience.snark.SnarkEnvironment
|
||||||
|
import kotlin.contracts.InvocationKind
|
||||||
|
import kotlin.contracts.contract
|
||||||
|
|
||||||
|
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(siteBuilder, 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)
|
||||||
|
}
|
@ -20,27 +20,32 @@ import space.kscience.dataforge.meta.withDefault
|
|||||||
import space.kscience.dataforge.names.Name
|
import space.kscience.dataforge.names.Name
|
||||||
import space.kscience.dataforge.names.cutLast
|
import space.kscience.dataforge.names.cutLast
|
||||||
import space.kscience.dataforge.names.endsWith
|
import space.kscience.dataforge.names.endsWith
|
||||||
|
import space.kscience.snark.SnarkEnvironment
|
||||||
import space.kscience.snark.html.*
|
import space.kscience.snark.html.*
|
||||||
import java.nio.file.Path
|
import java.nio.file.Path
|
||||||
import kotlin.contracts.InvocationKind
|
import kotlin.contracts.InvocationKind
|
||||||
import kotlin.contracts.contract
|
import kotlin.contracts.contract
|
||||||
|
import kotlin.io.path.isDirectory
|
||||||
|
|
||||||
@PublishedApi
|
@PublishedApi
|
||||||
internal class KtorSiteBuilder(
|
internal class KtorSiteBuilder(
|
||||||
override val snark: SnarkPlugin,
|
override val snark: SnarkHtmlPlugin,
|
||||||
override val data: DataTree<*>,
|
override val data: DataTree<*>,
|
||||||
override val siteMeta: Meta,
|
override val siteMeta: Meta,
|
||||||
private val baseUrl: String,
|
private val baseUrl: String,
|
||||||
private val ktorRoute: Route,
|
private val ktorRoute: Route,
|
||||||
) : SiteBuilder {
|
) : SiteBuilder {
|
||||||
|
|
||||||
override fun assetFile(remotePath: String, file: Path) {
|
override fun file(file: Path, remotePath: String) {
|
||||||
ktorRoute.file(remotePath, file.toFile())
|
if (file.isDirectory()) {
|
||||||
}
|
ktorRoute.static(remotePath) {
|
||||||
|
//TODO check non-standard FS and convert
|
||||||
override fun assetDirectory(remotePath: String, directory: Path) {
|
files(file.toFile())
|
||||||
ktorRoute.static(remotePath) {
|
}
|
||||||
files(directory.toFile())
|
} else if (remotePath.isBlank()) {
|
||||||
|
error("Can't mount file to an empty route")
|
||||||
|
} else {
|
||||||
|
ktorRoute.file(remotePath, file.toFile())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,11 +58,11 @@ internal class KtorSiteBuilder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
inner class KtorPage(
|
inner class KtorWebPage(
|
||||||
val pageBaseUrl: String,
|
val pageBaseUrl: String,
|
||||||
override val pageMeta: Meta = this@KtorSiteBuilder.siteMeta,
|
override val pageMeta: Meta = this@KtorSiteBuilder.siteMeta,
|
||||||
) : Page {
|
) : WebPage {
|
||||||
override val snark: SnarkPlugin get() = this@KtorSiteBuilder.snark
|
override val snark: SnarkHtmlPlugin get() = this@KtorSiteBuilder.snark
|
||||||
override val data: DataTree<*> get() = this@KtorSiteBuilder.data
|
override val data: DataTree<*> get() = this@KtorSiteBuilder.data
|
||||||
|
|
||||||
override fun resolveRef(ref: String): String = resolveRef(pageBaseUrl, ref)
|
override fun resolveRef(ref: String): String = resolveRef(pageBaseUrl, ref)
|
||||||
@ -69,7 +74,7 @@ internal class KtorSiteBuilder(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun page(route: Name, content: context(Page, HTML)() -> Unit) {
|
override fun page(route: Name, content: context(WebPage, HTML)() -> Unit) {
|
||||||
ktorRoute.get(route.toWebPath()) {
|
ktorRoute.get(route.toWebPath()) {
|
||||||
call.respondHtml {
|
call.respondHtml {
|
||||||
val request = call.request
|
val request = call.request
|
||||||
@ -79,7 +84,7 @@ internal class KtorSiteBuilder(
|
|||||||
host = request.host()
|
host = request.host()
|
||||||
port = request.port()
|
port = request.port()
|
||||||
}
|
}
|
||||||
val pageBuilder = KtorPage(url.buildString())
|
val pageBuilder = KtorWebPage(url.buildString())
|
||||||
content(pageBuilder, this)
|
content(pageBuilder, this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -103,34 +108,31 @@ internal class KtorSiteBuilder(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
override fun assetResourceFile(remotePath: String, resourcesPath: String) {
|
override fun resourceFile(remotePath: String, resourcesPath: String) {
|
||||||
ktorRoute.resource(resourcesPath, resourcesPath)
|
ktorRoute.resource(resourcesPath, resourcesPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun assetResourceDirectory(resourcesPath: String) {
|
override fun resourceDirectory(resourcesPath: String) {
|
||||||
ktorRoute.resources(resourcesPath)
|
ktorRoute.resources(resourcesPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public inline fun Route.snarkSite(
|
context(Route, SnarkEnvironment) public fun siteInRoute(
|
||||||
snark: SnarkPlugin,
|
|
||||||
data: DataTree<*>,
|
|
||||||
meta: Meta = data.meta,
|
|
||||||
block: SiteBuilder.() -> Unit,
|
block: SiteBuilder.() -> Unit,
|
||||||
) {
|
) {
|
||||||
contract {
|
contract {
|
||||||
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||||
}
|
}
|
||||||
block(KtorSiteBuilder(snark, data, meta, "", this@snarkSite))
|
block(KtorSiteBuilder(buildHtmlPlugin(), data, meta, "", this@Route))
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun Application.snarkSite(
|
context(Application) public fun SnarkEnvironment.site(
|
||||||
snark: SnarkPlugin,
|
|
||||||
data: DataTree<*> = DataTree.empty(),
|
|
||||||
meta: Meta = data.meta,
|
|
||||||
block: SiteBuilder.() -> Unit,
|
block: SiteBuilder.() -> Unit,
|
||||||
) {
|
) {
|
||||||
|
contract {
|
||||||
|
callsInPlace(block, InvocationKind.EXACTLY_ONCE)
|
||||||
|
}
|
||||||
routing {
|
routing {
|
||||||
snarkSite(snark, data, meta, block)
|
siteInRoute(block)
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user