diff --git a/build.gradle.kts b/build.gradle.kts index c34fa93..d1e56af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -9,10 +9,10 @@ allprojects { if(name!="snark-gradle-plugin") { tasks.withType { kotlinOptions { - freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" + freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" } } } } -val dataforgeVersion by extra("0.6.0-dev-9") \ No newline at end of file +val dataforgeVersion by extra("0.6.0-dev-10") \ No newline at end of file diff --git a/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkEnvironment.kt b/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkEnvironment.kt new file mode 100644 index 0000000..2f155bc --- /dev/null +++ b/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkEnvironment.kt @@ -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 get() = _data ?: DataTree.empty() + + public fun data(builder: DataSourceBuilder.() -> Unit) { + _data = DataTreeBuilder(typeOf(), 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() + public val plugins: Set 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) diff --git a/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkParser.kt b/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkParser.kt index ab50ba9..c64aae9 100644 --- a/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkParser.kt +++ b/snark-core/src/commonMain/kotlin/space/kscience/snark/SnarkParser.kt @@ -4,9 +4,12 @@ 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 @@ -31,4 +34,22 @@ public interface SnarkParser { public const val TYPE: String = "snark.parser" public const val DEFAULT_PRIORITY: Int = 10 } -} \ No newline at end of file +} + +@PublishedApi +internal class SnarkParserWrapper( + val reader: IOReader, + override val type: KType, + override val fileExtensions: Set, +) : SnarkParser { + 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 SnarkParser( + reader: IOReader, + vararg fileExtensions: String, +): SnarkParser = SnarkParserWrapper(reader, typeOf(), fileExtensions.toSet()) \ No newline at end of file diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/HtmlData.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/HtmlData.kt index bd88a89..95dbbaa 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/HtmlData.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/HtmlData.kt @@ -16,7 +16,7 @@ import space.kscience.snark.SnarkContext //typealias HtmlFragment = context(PageBuilder, TagConsumer<*>) () -> Unit 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 } @@ -26,7 +26,7 @@ public typealias HtmlData = Data // 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) } } diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/SiteBuilder.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/SiteBuilder.kt index 72cb61e..9adfc1a 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/SiteBuilder.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/SiteBuilder.kt @@ -4,15 +4,12 @@ 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.meta.Meta import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.NameToken import space.kscience.dataforge.names.parseAsName import space.kscience.snark.SnarkContext 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 { + /** + * Data used for site construction. The type of the data is not limited + */ 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 + /** + * Site configuration + */ 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) - - public fun page(route: Name = Name.EMPTY, content: context(Page, HTML) () -> Unit) + /** + * 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(WebPage, HTML) () -> 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 + * By default, the same data tree is used for route. */ public fun route( routeName: Name, @@ -90,11 +106,4 @@ public inline fun SiteBuilder.route( // route(route) { // withData(mountedData).block() // } -//} - -//TODO move to DF -public fun DataTree.Companion.empty(meta: Meta = Meta.EMPTY): DataTree = object : DataTree { - override val items: Map> get() = emptyMap() - override val dataType: KType get() = typeOf() - override val meta: Meta get() = meta -} \ No newline at end of file +//} \ No newline at end of file diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/SiteLayout.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/SiteLayout.kt index 9c21fd2..34e6143 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/SiteLayout.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/SiteLayout.kt @@ -30,27 +30,30 @@ internal fun SiteBuilder.assetsFrom(rootMeta: Meta) { path?.let { resourcePath -> //If remote path provided, use a single resource remotePath?.let { - assetResourceFile(it, resourcePath) + resourceFile(it, resourcePath) return@forEach } //otherwise use package resources - assetResourceDirectory(resourcePath) + resourceDirectory(resourcePath) } } rootMeta.getIndexed("file".asName()).forEach { (_, meta) -> val remotePath by meta.string { error("File remote 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) -> 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) -> Unit /** @@ -114,7 +117,9 @@ public fun SiteBuilder.pages( pages(dataPath.parseAsName(), remotePath, dataRenderer = dataRenderer) } - +/** + * An abstraction to render singular data or a data tree. + */ @Type(SiteLayout.TYPE) 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 { context(SiteBuilder) override fun render(item: DataTreeItem<*>) { pages(item) diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/SnarkPlugin.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/SnarkHtmlPlugin.kt similarity index 68% rename from snark-html/src/main/kotlin/space/kscience/snark/html/SnarkPlugin.kt rename to snark-html/src/main/kotlin/space/kscience/snark/html/SnarkHtmlPlugin.kt index 25b33e1..ea0413e 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/SnarkPlugin.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/SnarkHtmlPlugin.kt @@ -3,7 +3,9 @@ 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.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.YamlPlugin 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.workspace.FileData import space.kscience.dataforge.workspace.readDataDirectory +import space.kscience.snark.SnarkEnvironment import space.kscience.snark.SnarkParser import java.nio.file.Path import kotlin.io.path.extension import kotlin.reflect.KClass -import kotlin.reflect.KType -import kotlin.reflect.typeOf - -@PublishedApi -internal class SnarkParserWrapper( - val reader: IOReader, - override val type: KType, - override val fileExtensions: Set, -) : SnarkParser { - 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 inline fun SnarkParser( - reader: IOReader, - vararg fileExtensions: String, -): SnarkParser = SnarkParserWrapper(reader, typeOf(), fileExtensions.toSet()) - -public class SnarkPlugin : AbstractPlugin() { +public class SnarkHtmlPlugin : AbstractPlugin() { private val yaml by require(YamlPlugin) public val io: IOPlugin get() = yaml.io @@ -54,8 +40,8 @@ public class SnarkPlugin : AbstractPlugin() { context.gather(SiteLayout.TYPE, true) } - private val textTransformations: Map by lazy { - context.gather(TextTransformation.TYPE, true) + private val textProcessors: Map by lazy { + context.gather(TextProcessor.TYPE, true) } 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") } - internal fun textTransformation(transformationMeta: Meta): TextTransformation { + internal fun textProcessor(transformationMeta: Meta): TextProcessor { val transformationName = transformationMeta.string ?: 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") } @@ -81,17 +67,17 @@ public class SnarkPlugin : AbstractPlugin() { "jpg".asName() to SnarkParser(ImageIOReader, "jpg", "jpeg"), "gif".asName() to SnarkParser(ImageIOReader, "gif"), ) - TextTransformation.TYPE -> mapOf( - "basic".asName() to BasicTextTransformation + TextProcessor.TYPE -> mapOf( + "basic".asName() to BasicTextProcessor ) else -> super.content(target) } - public companion object : PluginFactory { + public companion object : PluginFactory { override val tag: PluginTag = PluginTag("snark") - override val type: KClass = SnarkPlugin::class + override val type: KClass = SnarkHtmlPlugin::class - override fun build(context: Context, meta: Meta): SnarkPlugin = SnarkPlugin() + override fun build(context: Context, meta: Meta): SnarkHtmlPlugin = SnarkHtmlPlugin() private val byteArrayIOReader = IOReader { 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) -public fun SnarkPlugin.readDirectory(path: Path): DataTree = io.readDataDirectory(path) { dataPath, meta -> +public fun SnarkHtmlPlugin.readDirectory(path: Path): DataTree = io.readDataDirectory(path) { dataPath, meta -> val fileExtension = meta[FileData.META_FILE_EXTENSION_KEY].string ?: dataPath.extension val parser: SnarkParser = parsers.values.filter { parser -> fileExtension in parser.fileExtensions @@ -110,7 +109,7 @@ public fun SnarkPlugin.readDirectory(path: Path): DataTree = io.readDataDir it.priority } ?: run { logger.warn { "The parser is not found for file $dataPath with meta $meta" } - SnarkPlugin.byteArraySnarkParser + SnarkHtmlPlugin.byteArraySnarkParser } parser.reader(context, meta) diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/SnarkTextParser.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/SnarkTextParser.kt index 2babc2c..b99ed52 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/SnarkTextParser.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/SnarkTextParser.kt @@ -23,9 +23,9 @@ public abstract class SnarkTextParser : SnarkParser { override fun parse(context: Context, meta: Meta, bytes: ByteArray): R = parseText(bytes.decodeToString(), meta) - public fun transformText(text: String, meta: Meta, page: Page): String = - meta[TextTransformation.TEXT_TRANSFORMATION_KEY]?.let { - with(page) { page.snark.textTransformation(it).transform(text) } + 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 } diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt index 8c4411b..42ca85a 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/StaticSiteBuilder.kt @@ -8,6 +8,7 @@ import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.withDefault import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.isEmpty +import space.kscience.snark.SnarkEnvironment import java.nio.file.Files import java.nio.file.Path import kotlin.contracts.InvocationKind @@ -15,12 +16,15 @@ import kotlin.contracts.contract import kotlin.io.path.* +/** + * An implementation of [SiteBuilder] to render site as a static directory [outputPath] + */ internal class StaticSiteBuilder( - override val snark: SnarkPlugin, + override val snark: SnarkHtmlPlugin, override val data: DataTree<*>, override val siteMeta: Meta, private val baseUrl: String, - private val path: Path, + private val outputPath: Path, ) : SiteBuilder { private fun Path.copyRecursively(target: Path) { Files.walk(this).forEach { source: Path -> @@ -32,27 +36,28 @@ internal class StaticSiteBuilder( } } - override fun assetFile(remotePath: String, file: Path) { - val targetPath = path.resolve(remotePath) - targetPath.parent.createDirectories() - file.copyTo(targetPath, true) + override fun file(file: Path, remotePath: String) { + val targetPath = outputPath.resolve(remotePath) + if(file.isDirectory()){ + 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) { - val targetPath = path.resolve(remotePath) - targetPath.parent.createDirectories() - directory.copyRecursively(targetPath) - } - - override fun assetResourceFile(remotePath: String, resourcesPath: String) { - val targetPath = path.resolve(remotePath) + override fun resourceFile(remotePath: String, resourcesPath: String) { + val targetPath = outputPath.resolve(remotePath) targetPath.parent.createDirectories() javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyTo(targetPath, true) } - override fun assetResourceDirectory(resourcesPath: String) { - path.parent.createDirectories() - javaClass.getResource(resourcesPath)?.let { Path.of(it.toURI()) }?.copyRecursively(path) + 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()) { @@ -63,10 +68,10 @@ internal class StaticSiteBuilder( "${baseUrl.removeSuffix("/")}/$ref" } - inner class StaticPage : Page { + inner class StaticWebPage : WebPage { override val data: DataTree<*> get() = this@StaticSiteBuilder.data 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) @@ -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() htmlBuilder.html { - content(StaticPage(), this) + content(StaticWebPage(), this) } val newPath = if (route.isEmpty()) { - path.resolve("index.html") + outputPath.resolve("index.html") } else { - path.resolve(route.toWebPath() + ".html") + outputPath.resolve(route.toWebPath() + ".html") } newPath.parent.createDirectories() @@ -108,18 +113,23 @@ internal class StaticSiteBuilder( } else { 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, - data: DataTree<*> = DataTree.empty(), siteUrl: String = outputPath.absolutePathString().replace("\\", "/"), block: SiteBuilder.() -> Unit, ) { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } - StaticSiteBuilder(this, data, meta, siteUrl, outputPath).block() + val plugin = buildHtmlPlugin() + StaticSiteBuilder(plugin, data, meta, siteUrl, outputPath).block() } \ No newline at end of file diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/TextProcessor.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/TextProcessor.kt new file mode 100644 index 0000000..cb67866 --- /dev/null +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/TextProcessor.kt @@ -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 + } + } +} + + diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/TextTransformation.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/TextTransformation.kt deleted file mode 100644 index 9792f4d..0000000 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/TextTransformation.kt +++ /dev/null @@ -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 - } - } - } -} - - diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/Page.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/WebPage.kt similarity index 83% rename from snark-html/src/main/kotlin/space/kscience/snark/html/Page.kt rename to snark-html/src/main/kotlin/space/kscience/snark/html/WebPage.kt index e51d606..05bd9c2 100644 --- a/snark-html/src/main/kotlin/space/kscience/snark/html/Page.kt +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/WebPage.kt @@ -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 @@ -32,11 +32,11 @@ public interface Page : ContextAware, SnarkContext { 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 diff --git a/snark-html/src/main/kotlin/space/kscience/snark/html/htmlEnvironment.kt b/snark-html/src/main/kotlin/space/kscience/snark/html/htmlEnvironment.kt new file mode 100644 index 0000000..0989398 --- /dev/null +++ b/snark-html/src/main/kotlin/space/kscience/snark/html/htmlEnvironment.kt @@ -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 = 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 = when (target) { + SiteLayout.TYPE -> envBuilder.layouts + else -> super.content(target) + } + } + registerPlugin(plugin) +} \ No newline at end of file diff --git a/snark-ktor/src/main/kotlin/space/kscience/snark/ktor/KtorSiteBuilder.kt b/snark-ktor/src/main/kotlin/space/kscience/snark/ktor/KtorSiteBuilder.kt index 9b8de86..1451e15 100644 --- a/snark-ktor/src/main/kotlin/space/kscience/snark/ktor/KtorSiteBuilder.kt +++ b/snark-ktor/src/main/kotlin/space/kscience/snark/ktor/KtorSiteBuilder.kt @@ -20,27 +20,32 @@ import space.kscience.dataforge.meta.withDefault import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.cutLast import space.kscience.dataforge.names.endsWith +import space.kscience.snark.SnarkEnvironment import space.kscience.snark.html.* import java.nio.file.Path import kotlin.contracts.InvocationKind import kotlin.contracts.contract +import kotlin.io.path.isDirectory @PublishedApi internal class KtorSiteBuilder( - override val snark: SnarkPlugin, + override val snark: SnarkHtmlPlugin, override val data: DataTree<*>, override val siteMeta: Meta, private val baseUrl: String, private val ktorRoute: Route, ) : SiteBuilder { - override fun assetFile(remotePath: String, file: Path) { - ktorRoute.file(remotePath, file.toFile()) - } - - override fun assetDirectory(remotePath: String, directory: Path) { - ktorRoute.static(remotePath) { - files(directory.toFile()) + override fun file(file: Path, remotePath: String) { + if (file.isDirectory()) { + ktorRoute.static(remotePath) { + //TODO check non-standard FS and convert + files(file.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, override val pageMeta: Meta = this@KtorSiteBuilder.siteMeta, - ) : Page { - override val snark: SnarkPlugin get() = this@KtorSiteBuilder.snark + ) : WebPage { + override val snark: SnarkHtmlPlugin get() = this@KtorSiteBuilder.snark override val data: DataTree<*> get() = this@KtorSiteBuilder.data 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()) { call.respondHtml { val request = call.request @@ -79,7 +84,7 @@ internal class KtorSiteBuilder( host = request.host() port = request.port() } - val pageBuilder = KtorPage(url.buildString()) + val pageBuilder = KtorWebPage(url.buildString()) 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) } - override fun assetResourceDirectory(resourcesPath: String) { + override fun resourceDirectory(resourcesPath: String) { ktorRoute.resources(resourcesPath) } } -public inline fun Route.snarkSite( - snark: SnarkPlugin, - data: DataTree<*>, - meta: Meta = data.meta, +context(Route, SnarkEnvironment) public fun siteInRoute( block: SiteBuilder.() -> Unit, ) { contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } - block(KtorSiteBuilder(snark, data, meta, "", this@snarkSite)) + block(KtorSiteBuilder(buildHtmlPlugin(), data, meta, "", this@Route)) } -public fun Application.snarkSite( - snark: SnarkPlugin, - data: DataTree<*> = DataTree.empty(), - meta: Meta = data.meta, +context(Application) public fun SnarkEnvironment.site( block: SiteBuilder.() -> Unit, ) { + contract { + callsInPlace(block, InvocationKind.EXACTLY_ONCE) + } routing { - snarkSite(snark, data, meta, block) + siteInRoute(block) } } \ No newline at end of file